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    /// A transport that fails every round with one `causeKind`.
1992    struct CauseTransport(&'static str);
1993
1994    impl Transport for CauseTransport {
1995        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1996            let mut error = TransportError::new("transport.failed", "no route");
1997            error.details = Some(serde_json::json!({ "causeKind": self.0 }));
1998            Err(error)
1999        }
2000
2001        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2002            self.sync(request)
2003        }
2004
2005        fn download_segment(
2006            &mut self,
2007            _request: &SegmentRequest,
2008            _on_progress: &mut dyn FnMut(u64),
2009        ) -> Result<Vec<u8>, TransportError> {
2010            Err(TransportError::new("sync.transport_failed", "offline"))
2011        }
2012
2013        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2014            Ok(())
2015        }
2016
2017        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2018            Ok(())
2019        }
2020
2021        fn realtime_close(&mut self) -> Result<(), TransportError> {
2022            Ok(())
2023        }
2024    }
2025
2026    #[test]
2027    fn an_unreachable_transport_schedules_no_retry() {
2028        // §1.8: `connect` (native) and `network` (browser fetch) describe a
2029        // refused or unreachable endpoint; neither earns another backoff.
2030        for cause in ["connect", "network"] {
2031            let mut client = client();
2032            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2033            let mut transport = CauseTransport(cause);
2034            assert!(matches!(
2035                client.sync(&mut transport),
2036                SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.failed"
2037            ));
2038            assert!(
2039                client.drain_sync_intents().is_empty(),
2040                "{cause} scheduled a retry"
2041            );
2042        }
2043
2044        // Every other retryable transport cause keeps the background retry.
2045        let mut client = client();
2046        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2047        let mut transport = CauseTransport("timeout");
2048        assert!(matches!(
2049            client.sync(&mut transport),
2050            SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.failed"
2051        ));
2052        assert!(matches!(
2053            client.drain_sync_intents().as_slice(),
2054            [SyncIntent::Background { delay_ms: 250 }]
2055        ));
2056    }
2057
2058    #[test]
2059    fn sync_until_idle_reports_its_total_deadline() {
2060        assert_eq!(SyncClient::SYNC_IDLE_TIMEOUT_MS, 60_000);
2061        let mut client = client();
2062        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2063        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2064
2065        // A zero deadline is a caller error, like a zero round budget.
2066        let invalid = client.sync_until_idle_with_deadline(&mut transport, None, Some(0));
2067        assert!(
2068            matches!(invalid, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
2069            "{invalid:?}"
2070        );
2071
2072        // The expired deadline stops before the first round and reports how
2073        // many rounds ran.
2074        let now = client.clock_now_ms();
2075        let outcome = client.sync_until_idle_bounded(&mut transport, None, now, Some(now));
2076        let SyncOutcome::Failed {
2077            error_code,
2078            details,
2079            recommended_action,
2080            ..
2081        } = outcome
2082        else {
2083            panic!("expected the idle deadline, got {outcome:?}");
2084        };
2085        assert_eq!(error_code, "sync.idle_timeout");
2086        assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2087        let details = details.expect("deadline details");
2088        assert_eq!(details["timeoutMs"], json!(0));
2089        assert_eq!(details["rounds"], json!(0));
2090    }
2091
2092    /// Returns one pre-built response per round so a test can pin a successful
2093    /// work-bearing round followed by a failure or a schema floor.
2094    struct ScriptedSyncTransport {
2095        responses: std::collections::VecDeque<Message>,
2096        calls: usize,
2097    }
2098
2099    impl Transport for ScriptedSyncTransport {
2100        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2101            self.calls += 1;
2102            let response = self.responses.pop_front().expect("scripted response");
2103            Ok(encode_message(&response))
2104        }
2105
2106        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2107            self.sync(request)
2108        }
2109
2110        fn download_segment(
2111            &mut self,
2112            _request: &SegmentRequest,
2113            _on_progress: &mut dyn FnMut(u64),
2114        ) -> Result<Vec<u8>, TransportError> {
2115            Err(TransportError::new("sync.transport_failed", "offline"))
2116        }
2117
2118        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2119            Ok(())
2120        }
2121
2122        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2123            Ok(())
2124        }
2125
2126        fn realtime_close(&mut self) -> Result<(), TransportError> {
2127            Ok(())
2128        }
2129    }
2130
2131    #[test]
2132    fn sync_until_idle_success_then_failure_keeps_earlier_work() {
2133        let path = std::env::temp_dir().join(format!(
2134            "syncular-budget-script-{}.sqlite",
2135            uuid::Uuid::new_v4()
2136        ));
2137        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2138            {"name":"id","type":"string","nullable":false},
2139            {"name":"project_id","type":"string","nullable":false}],
2140            "scopes":[{"pattern":"project:{project_id}"}]}]});
2141        let mut client = SyncClient::open_path(
2142            "budget-script".into(),
2143            &schema,
2144            ClientLimits::default(),
2145            path.to_str().unwrap(),
2146        )
2147        .expect("open client");
2148        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2149        client
2150            .subscribe(
2151                "tasks".into(),
2152                "tasks".into(),
2153                vec![("project_id".into(), vec!["p1".into()])],
2154                None,
2155            )
2156            .unwrap();
2157        client.subs[0].cursor = 0;
2158        client.subs[0].synced_once = true;
2159        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2160        client.persist_sub(&client.subs[0]).unwrap();
2161        client.drain_change_batches();
2162
2163        let row = encode_row_json(
2164            client.schema.table("tasks").unwrap(),
2165            "kept",
2166            &Map::from_iter([
2167                ("id".into(), json!("kept")),
2168                ("project_id".into(), json!("p1")),
2169            ]),
2170            &client.encryption,
2171        )
2172        .unwrap();
2173        let success = Message {
2174            wire_version: WIRE_VERSION,
2175            msg_kind: MsgKind::Response,
2176            frames: vec![
2177                Frame::RespHeader {
2178                    required_schema_version: None,
2179                    latest_schema_version: None,
2180                    log_epoch: Some("epoch-1".into()),
2181                    reset_required: Some(false),
2182                },
2183                Frame::SubStart {
2184                    id: "tasks".into(),
2185                    status: SubStatus::Active,
2186                    reason_code: String::new(),
2187                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2188                    bootstrap: false,
2189                },
2190                Frame::Commit {
2191                    commit_seq: 1,
2192                    created_at_ms: 1,
2193                    actor_id: "writer".into(),
2194                    tables: vec!["tasks".into()],
2195                    changes: vec![ssp2::model::Change {
2196                        table_index: 0,
2197                        row_id: "kept".into(),
2198                        op: Op::Upsert,
2199                        row_version: Some(1),
2200                        scopes: vec![("project_id".into(), "p1".into())],
2201                        row: Some(row),
2202                    }],
2203                },
2204                Frame::SubEnd {
2205                    next_cursor: 1,
2206                    bootstrap_state: None,
2207                },
2208            ],
2209        };
2210        let failure = Message {
2211            wire_version: WIRE_VERSION,
2212            msg_kind: MsgKind::Response,
2213            frames: vec![
2214                Frame::RespHeader {
2215                    required_schema_version: None,
2216                    latest_schema_version: None,
2217                    log_epoch: Some("epoch-1".into()),
2218                    reset_required: Some(false),
2219                },
2220                Frame::Error {
2221                    code: "sync.internal_error".into(),
2222                    message: "second round failed".into(),
2223                    category: "internal".into(),
2224                    retryable: false,
2225                    recommended_action: "retryLater".into(),
2226                    details: None,
2227                },
2228            ],
2229        };
2230        let mut transport = ScriptedSyncTransport {
2231            responses: std::collections::VecDeque::from([success, failure]),
2232            calls: 0,
2233        };
2234        let outcome = client.sync_until_idle(&mut transport, Some(2));
2235        let SyncOutcome::Failed {
2236            error_code,
2237            recommended_action,
2238            ..
2239        } = outcome
2240        else {
2241            panic!("expected the in-band failure");
2242        };
2243        assert_eq!(error_code, "sync.internal_error");
2244        assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2245        assert_eq!(
2246            transport.calls, 2,
2247            "one successful round then the failing round"
2248        );
2249        assert_eq!(
2250            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2251            json!("kept"),
2252            "the earlier round's durable work survives the later failure"
2253        );
2254        drop(client);
2255        let _ = std::fs::remove_file(&path);
2256    }
2257
2258    #[test]
2259    fn sync_until_idle_stops_on_a_schema_floor() {
2260        let mut client = client();
2261        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2262        let floor = Message {
2263            wire_version: WIRE_VERSION,
2264            msg_kind: MsgKind::Response,
2265            frames: vec![Frame::RespHeader {
2266                required_schema_version: Some(2),
2267                latest_schema_version: Some(2),
2268                log_epoch: Some("epoch-1".into()),
2269                reset_required: Some(false),
2270            }],
2271        };
2272        let mut transport = ScriptedSyncTransport {
2273            responses: std::collections::VecDeque::from([floor.clone(), floor]),
2274            calls: 0,
2275        };
2276        let outcome = client.sync_until_idle(&mut transport, Some(3));
2277        match outcome {
2278            SyncOutcome::Ok(report) => assert_eq!(
2279                report
2280                    .schema_floor
2281                    .as_ref()
2282                    .and_then(|floor| floor.required_schema_version),
2283                Some(2),
2284                "the stop retains the latest schema floor"
2285            ),
2286            other => panic!("expected a schema-floor stop, got {other:?}"),
2287        }
2288        assert_eq!(
2289            transport.calls, 1,
2290            "a schema floor stops the budget loop after one round"
2291        );
2292    }
2293
2294    /// Records the decoded request of every round and answers from a script of
2295    /// round results, so a test can pin what the following request carries.
2296    struct FailureScriptTransport {
2297        rounds: std::collections::VecDeque<Result<Vec<u8>, TransportError>>,
2298        requests: Vec<Message>,
2299    }
2300
2301    impl Transport for FailureScriptTransport {
2302        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2303            self.requests
2304                .push(decode_message(request).expect("the client encodes a legal request"));
2305            self.rounds.pop_front().expect("scripted round")
2306        }
2307
2308        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2309            self.sync(request)
2310        }
2311
2312        fn download_segment(
2313            &mut self,
2314            _request: &SegmentRequest,
2315            _on_progress: &mut dyn FnMut(u64),
2316        ) -> Result<Vec<u8>, TransportError> {
2317            Err(TransportError::new("transport.timeout", "segment stalled"))
2318        }
2319
2320        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2321            Ok(())
2322        }
2323
2324        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2325            Ok(())
2326        }
2327
2328        fn realtime_close(&mut self) -> Result<(), TransportError> {
2329            Ok(())
2330        }
2331    }
2332
2333    /// A complete response: RESP_HEADER for the client's epoch plus `frames`.
2334    fn completed_response(frames: Vec<Frame>) -> Vec<u8> {
2335        let mut all = vec![Frame::RespHeader {
2336            required_schema_version: None,
2337            latest_schema_version: None,
2338            log_epoch: Some("epoch-1".into()),
2339            reset_required: Some(false),
2340        }];
2341        all.extend(frames);
2342        encode_message(&Message {
2343            wire_version: WIRE_VERSION,
2344            msg_kind: MsgKind::Response,
2345            frames: all,
2346        })
2347    }
2348
2349    fn carried_round_failures(request: &Message) -> Vec<String> {
2350        request
2351            .frames
2352            .iter()
2353            .filter_map(|frame| match frame {
2354                Frame::RoundFailure { code } => Some(code.clone()),
2355                _ => None,
2356            })
2357            .collect()
2358    }
2359
2360    fn failure_transport(
2361        rounds: impl IntoIterator<Item = Result<Vec<u8>, TransportError>>,
2362    ) -> FailureScriptTransport {
2363        FailureScriptTransport {
2364            rounds: rounds.into_iter().collect(),
2365            requests: Vec::new(),
2366        }
2367    }
2368
2369    #[test]
2370    fn a_round_without_a_complete_response_reports_its_code_on_every_following_request() {
2371        let mut client = client();
2372        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2373        let mut transport = failure_transport([
2374            Err(TransportError::new("transport.timeout", "round timed out")),
2375            Ok(completed_response(Vec::new())),
2376            Ok(completed_response(Vec::new())),
2377        ]);
2378        for _ in 0..3 {
2379            client.sync(&mut transport);
2380        }
2381        assert!(carried_round_failures(&transport.requests[0]).is_empty());
2382        assert_eq!(
2383            carried_round_failures(&transport.requests[1]),
2384            vec!["transport.timeout"]
2385        );
2386        assert!(
2387            matches!(transport.requests[1].frames[0], Frame::ReqHeader { .. })
2388                && matches!(transport.requests[1].frames[1], Frame::RoundFailure { .. }),
2389            "the report sits immediately after REQ_HEADER"
2390        );
2391        assert!(
2392            carried_round_failures(&transport.requests[2]).is_empty(),
2393            "a complete response ends the report"
2394        );
2395    }
2396
2397    #[test]
2398    fn a_newer_round_failure_replaces_the_kept_code() {
2399        let mut client = client();
2400        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2401        let mut transport = failure_transport([
2402            Err(TransportError::new("transport.timeout", "round timed out")),
2403            Err(TransportError::new("transport.failed", "socket closed")),
2404            Ok(completed_response(Vec::new())),
2405            Ok(completed_response(Vec::new())),
2406        ]);
2407        for _ in 0..4 {
2408            client.sync(&mut transport);
2409        }
2410        assert_eq!(
2411            carried_round_failures(&transport.requests[1]),
2412            vec!["transport.timeout"]
2413        );
2414        assert_eq!(
2415            carried_round_failures(&transport.requests[2]),
2416            vec!["transport.failed"]
2417        );
2418        assert!(carried_round_failures(&transport.requests[3]).is_empty());
2419    }
2420
2421    #[test]
2422    fn an_in_band_error_completes_the_round_and_is_never_reported() {
2423        let mut client = client();
2424        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2425        let mut transport = failure_transport([
2426            Err(TransportError::new("transport.timeout", "round timed out")),
2427            Ok(completed_response(vec![Frame::Error {
2428                code: "sync.internal_error".into(),
2429                message: "server error".into(),
2430                category: "internal".into(),
2431                retryable: false,
2432                recommended_action: "retryLater".into(),
2433                details: None,
2434            }])),
2435            Ok(completed_response(Vec::new())),
2436        ]);
2437        client.sync(&mut transport);
2438        assert!(matches!(
2439            client.sync(&mut transport),
2440            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
2441        ));
2442        client.sync(&mut transport);
2443        assert_eq!(
2444            carried_round_failures(&transport.requests[1]),
2445            vec!["transport.timeout"]
2446        );
2447        assert!(
2448            carried_round_failures(&transport.requests[2]).is_empty(),
2449            "the server already recorded an in-band ERROR"
2450        );
2451    }
2452
2453    #[test]
2454    fn a_silent_segment_fetch_is_retryable_and_reported_on_the_next_request() {
2455        let mut client = client();
2456        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2457        client
2458            .subscribe(
2459                "tasks".into(),
2460                "tasks".into(),
2461                vec![("project_id".into(), vec!["p1".into()])],
2462                None,
2463            )
2464            .unwrap();
2465        client.subs[0].cursor = 0;
2466        client.subs[0].synced_once = true;
2467        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2468        client.persist_sub(&client.subs[0]).unwrap();
2469        client.drain_change_batches();
2470        client.drain_sync_intents();
2471        let mut transport = failure_transport([
2472            Ok(completed_response(vec![
2473                Frame::SubStart {
2474                    id: "tasks".into(),
2475                    status: SubStatus::Active,
2476                    reason_code: String::new(),
2477                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2478                    bootstrap: false,
2479                },
2480                Frame::SegmentRef {
2481                    segment_id: "sha256:stalled".into(),
2482                    media_type: MediaType::Rows,
2483                    table: "tasks".into(),
2484                    byte_length: 1,
2485                    row_count: 1,
2486                    as_of_commit_seq: 1,
2487                    scope_digest: "digest".into(),
2488                    row_cursor: None,
2489                    next_row_cursor: None,
2490                    url: None,
2491                    url_expires_at_ms: None,
2492                },
2493                Frame::SubEnd {
2494                    next_cursor: 1,
2495                    bootstrap_state: None,
2496                },
2497            ])),
2498            Ok(completed_response(Vec::new())),
2499            Ok(completed_response(Vec::new())),
2500        ]);
2501
2502        assert!(matches!(
2503            client.sync(&mut transport),
2504            SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
2505        ));
2506        assert!(
2507            matches!(
2508                client.drain_sync_intents().as_slice(),
2509                [SyncIntent::Background { delay_ms: 250 }]
2510            ),
2511            "a silent transfer asks the host for a Background retry"
2512        );
2513        client.sync(&mut transport);
2514        client.sync(&mut transport);
2515        assert!(carried_round_failures(&transport.requests[0]).is_empty());
2516        assert_eq!(
2517            carried_round_failures(&transport.requests[1]),
2518            vec!["transport.timeout"],
2519            "the round's own response was complete, so the fetch failure rides the next request"
2520        );
2521        assert!(
2522            carried_round_failures(&transport.requests[2]).is_empty(),
2523            "a complete response ends the report"
2524        );
2525    }
2526
2527    #[test]
2528    fn a_code_the_frame_grammar_rejects_is_not_attached() {
2529        let mut client = client();
2530        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2531        let mut transport = failure_transport([
2532            Err(TransportError::new("offline", "no dotted code")),
2533            Ok(completed_response(Vec::new())),
2534        ]);
2535        client.sync(&mut transport);
2536        client.sync(&mut transport);
2537        assert!(carried_round_failures(&transport.requests[1]).is_empty());
2538    }
2539
2540    #[test]
2541    fn sync_report_merge_schema_floor_is_latest_state() {
2542        let mut aggregate = SyncReport {
2543            schema_floor: Some(SchemaFloor {
2544                required_schema_version: Some(2),
2545                latest_schema_version: Some(2),
2546            }),
2547            ..SyncReport::default()
2548        };
2549        aggregate.merge(&SyncReport::default());
2550        assert!(
2551            aggregate.schema_floor.is_none(),
2552            "a later round without a floor clears it"
2553        );
2554    }
2555
2556    #[test]
2557    fn push_capacity_limits_are_hard_and_keep_intent() {
2558        // The configured operation cap is hard for the first commit.
2559        let mut c = client();
2560        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2561        c.limits.max_push_operations_per_request = Some(1);
2562        let id = c
2563            .mutate(vec![
2564                Mutation::Upsert {
2565                    table: "tasks".into(),
2566                    values: Map::from_iter([
2567                        ("id".into(), json!("a")),
2568                        ("project_id".into(), json!("p1")),
2569                    ]),
2570                    base_version: None,
2571                },
2572                Mutation::Upsert {
2573                    table: "tasks".into(),
2574                    values: Map::from_iter([
2575                        ("id".into(), json!("b")),
2576                        ("project_id".into(), json!("p1")),
2577                    ]),
2578                    base_version: None,
2579                },
2580            ])
2581            .unwrap();
2582        let error = c.build_request(false).unwrap_err();
2583        let SyncOutcome::Failed {
2584            error_code,
2585            details,
2586            ..
2587        } = *error
2588        else {
2589            panic!("expected a capacity failure");
2590        };
2591        assert_eq!(error_code, "client.push_request_too_large");
2592        let details = details.expect("capacity details");
2593        assert_eq!(details["kind"], json!("operations"));
2594        assert_eq!(details["limit"], json!(1));
2595        assert_eq!(details["size"], json!(2));
2596        assert_eq!(details["clientCommitId"], json!(id));
2597        assert_eq!(c.pending_commit_ids(), vec![id]);
2598
2599        // The commit-count cap defers whole later commits.
2600        let mut c = client();
2601        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2602        c.limits.max_push_commits_per_request = Some(1);
2603        let first = c
2604            .mutate(vec![Mutation::Upsert {
2605                table: "tasks".into(),
2606                values: Map::from_iter([
2607                    ("id".into(), json!("one")),
2608                    ("project_id".into(), json!("p1")),
2609                ]),
2610                base_version: None,
2611            }])
2612            .unwrap();
2613        let second = c
2614            .mutate(vec![Mutation::Upsert {
2615                table: "tasks".into(),
2616                values: Map::from_iter([
2617                    ("id".into(), json!("two")),
2618                    ("project_id".into(), json!("p1")),
2619                ]),
2620                base_version: None,
2621            }])
2622            .unwrap();
2623        let (_, meta) = c.build_request(false).unwrap();
2624        assert_eq!(meta.pushed_ids, vec![first.clone()]);
2625        assert_eq!(meta.deferred_commits, 1);
2626        assert_eq!(c.pending_commit_ids(), vec![first, second]);
2627    }
2628
2629    #[test]
2630    fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
2631        // Two small commits: a cap just below the full request defers the
2632        // last commit and sends the fitting prefix (header + first commit).
2633        let mut c = client();
2634        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2635        let first = c
2636            .mutate(vec![Mutation::Upsert {
2637                table: "tasks".into(),
2638                values: Map::from_iter([
2639                    ("id".into(), json!("one")),
2640                    ("project_id".into(), json!("p1")),
2641                ]),
2642                base_version: None,
2643            }])
2644            .unwrap();
2645        let second = c
2646            .mutate(vec![Mutation::Upsert {
2647                table: "tasks".into(),
2648                values: Map::from_iter([
2649                    ("id".into(), json!("two")),
2650                    ("project_id".into(), json!("p1")),
2651                ]),
2652                base_version: None,
2653            }])
2654            .unwrap();
2655        let (message, meta) = c.build_request(false).unwrap();
2656        assert_eq!(meta.pushed_ids.len(), 2);
2657        let full = encode_message(&message).len();
2658        c.limits.max_push_request_bytes = Some(full - 1);
2659        let (_, meta) = c.build_request(false).unwrap();
2660        assert_eq!(meta.pushed_ids, vec![first.clone()]);
2661        assert_eq!(meta.deferred_commits, 1);
2662        assert_eq!(c.pending_commit_ids(), vec![first, second]);
2663
2664        // A single commit larger than the budget is a typed capacity error and
2665        // stays queued, reporting the full projected request size.
2666        let mut c = client();
2667        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2668        let huge = c
2669            .mutate(vec![Mutation::Upsert {
2670                table: "tasks".into(),
2671                values: Map::from_iter([
2672                    ("id".into(), json!("x".repeat(20_000))),
2673                    ("project_id".into(), json!("p1")),
2674                ]),
2675                base_version: None,
2676            }])
2677            .unwrap();
2678        let (message, meta) = c.build_request(false).unwrap();
2679        assert_eq!(meta.pushed_ids, vec![huge.clone()]);
2680        let with_huge = encode_message(&message).len();
2681        c.limits.max_push_request_bytes = Some(with_huge - 1);
2682        let error = c.build_request(false).unwrap_err();
2683        let SyncOutcome::Failed {
2684            error_code,
2685            details,
2686            ..
2687        } = *error
2688        else {
2689            panic!("expected a capacity failure");
2690        };
2691        assert_eq!(error_code, "client.push_request_too_large");
2692        let details = details.expect("capacity details");
2693        assert_eq!(details["kind"], json!("bytes"));
2694        assert_eq!(details["limit"], json!((with_huge - 1) as u64));
2695        assert_eq!(details["size"], json!(with_huge as u64));
2696        assert_eq!(details["clientCommitId"], json!(huge));
2697        assert_eq!(c.pending_commit_ids(), vec![huge]);
2698
2699        // A cap smaller than the fixed frames reports no blocked commit.
2700        let mut c = client();
2701        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2702        c.limits.max_push_request_bytes = Some(1);
2703        let error = c.build_request(false).unwrap_err();
2704        let SyncOutcome::Failed { details, .. } = *error else {
2705            panic!("expected a capacity failure");
2706        };
2707        let details = details.expect("capacity details");
2708        assert_eq!(details["kind"], json!("bytes"));
2709        assert!(details.get("clientCommitId").is_none(), "{details}");
2710    }
2711
2712    #[test]
2713    fn push_limits_validate_at_construction() {
2714        for limits in [
2715            ClientLimits {
2716                max_push_commits_per_request: Some(0),
2717                ..Default::default()
2718            },
2719            ClientLimits {
2720                max_push_operations_per_request: Some(0),
2721                ..Default::default()
2722            },
2723            ClientLimits {
2724                max_push_request_bytes: Some(0),
2725                ..Default::default()
2726            },
2727            ClientLimits {
2728                max_push_request_bytes: Some(u32::MAX as usize + 1),
2729                ..Default::default()
2730            },
2731        ] {
2732            let schema = json!({"version":1,"tables":[]});
2733            let error = SyncClient::with_connection(
2734                "bad-limits".into(),
2735                &schema,
2736                limits,
2737                Connection::open_in_memory().unwrap(),
2738            )
2739            .err();
2740            assert_eq!(
2741                error.as_deref(),
2742                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2743            );
2744        }
2745    }
2746
2747    #[test]
2748    fn open_path_rejects_invalid_push_limits_before_storage() {
2749        for identity in [false, true] {
2750            let path = std::env::temp_dir().join(format!(
2751                "syncular-bad-limits-{}.sqlite",
2752                uuid::Uuid::new_v4()
2753            ));
2754            let schema = json!({"version":1,"tables":[]});
2755            let limits = ClientLimits {
2756                max_push_request_bytes: Some(0),
2757                ..Default::default()
2758            };
2759            let error = if identity {
2760                SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2761            } else {
2762                SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2763            }
2764            .err();
2765            assert_eq!(
2766                error.as_deref(),
2767                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2768            );
2769            assert!(
2770                !path.exists(),
2771                "an invalid limit is rejected before the database file is created"
2772            );
2773        }
2774    }
2775
2776    #[test]
2777    fn failed_progress_retry_delay_doubles_to_the_cap() {
2778        let mut client = client();
2779        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2780        let mut transport = ServerErrorTransport {
2781            code: "sync.internal_error",
2782            retryable: true,
2783        };
2784        let delays: Vec<Option<u64>> = (0..9)
2785            .map(|_| {
2786                client.sync(&mut transport);
2787                client.progress().snapshot().unwrap().retry_delay_ms
2788            })
2789            .collect();
2790        assert_eq!(
2791            delays,
2792            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2793        );
2794    }
2795
2796    #[test]
2797    fn background_retry_deadlines_back_off_and_reset() {
2798        let mut client = client();
2799        client.schedule_background_retry();
2800        assert!(matches!(
2801            client.drain_sync_intents().as_slice(),
2802            [SyncIntent::Background { delay_ms: 250 }]
2803        ));
2804        client.schedule_background_retry();
2805        assert!(matches!(
2806            client.drain_sync_intents().as_slice(),
2807            [SyncIntent::Background { delay_ms: 500 }]
2808        ));
2809        client.reset_background_retry();
2810        client.schedule_background_retry();
2811        assert!(matches!(
2812            client.drain_sync_intents().as_slice(),
2813            [SyncIntent::Background { delay_ms: 250 }]
2814        ));
2815    }
2816
2817    #[test]
2818    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2819        let mut client = client();
2820        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2821        client
2822            .subscribe(
2823                "epoch-tasks".to_owned(),
2824                "tasks".to_owned(),
2825                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2826                None,
2827            )
2828            .expect("subscribe");
2829        client.drain_sync_intents();
2830
2831        client
2832            .run_log_epoch_reset("epoch-2")
2833            .expect("reset partition log epoch");
2834
2835        assert!(matches!(
2836            client.drain_sync_intents().as_slice(),
2837            [SyncIntent::Interactive]
2838        ));
2839    }
2840
2841    #[derive(Default)]
2842    struct CountingRealtimeTransport {
2843        connects: usize,
2844        closes: usize,
2845        messages: Vec<String>,
2846        blob_uploads: Vec<(String, Vec<u8>)>,
2847        blob_download: Option<Vec<u8>>,
2848        sync_calls: usize,
2849        realtime_sync_calls: usize,
2850        refuse_connect: bool,
2851    }
2852
2853    impl Transport for CountingRealtimeTransport {
2854        fn blob_upload(
2855            &mut self,
2856            blob_id: &str,
2857            bytes: &[u8],
2858            _media_type: Option<&str>,
2859        ) -> Result<(), TransportError> {
2860            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2861            Ok(())
2862        }
2863
2864        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2865            self.blob_download
2866                .clone()
2867                .map(BlobDownload::Bytes)
2868                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2869        }
2870
2871        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2872            self.sync_calls += 1;
2873            Err(TransportError::new("sync.transport_failed", "offline"))
2874        }
2875
2876        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2877            self.realtime_sync_calls += 1;
2878            Err(TransportError::new("sync.transport_failed", "offline"))
2879        }
2880
2881        fn download_segment(
2882            &mut self,
2883            _request: &SegmentRequest,
2884            _on_progress: &mut dyn FnMut(u64),
2885        ) -> Result<Vec<u8>, TransportError> {
2886            Err(TransportError::new("sync.transport_failed", "offline"))
2887        }
2888
2889        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2890            self.connects += 1;
2891            if self.refuse_connect {
2892                return Err(TransportError::new("transport.failed", "refused"));
2893            }
2894            Ok(())
2895        }
2896
2897        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2898            self.messages.push(text.to_owned());
2899            Ok(())
2900        }
2901
2902        fn realtime_close(&mut self) -> Result<(), TransportError> {
2903            self.closes += 1;
2904            Ok(())
2905        }
2906    }
2907
2908    #[test]
2909    fn blob_results_own_downloaded_bytes() {
2910        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2911            {"name":"id","type":"string","nullable":false},
2912            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2913        let bytes = b"owned typed result".to_vec();
2914        let blob_id = blob_id_for(&bytes);
2915        let mut transport = CountingRealtimeTransport {
2916            blob_download: Some(bytes.clone()),
2917            ..Default::default()
2918        };
2919        let result = {
2920            let mut client =
2921                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2922                    .unwrap();
2923            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2924            assert_eq!(result.blob_id, blob_id);
2925            assert_eq!(result.byte_length, bytes.len() as i64);
2926            assert_eq!(result.bytes, bytes);
2927            assert_eq!(result.media_type, None);
2928            assert_eq!(
2929                client
2930                    .conn
2931                    .query_row(
2932                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2933                        rusqlite::params![blob_id],
2934                        |row| row.get::<_, i64>(0),
2935                    )
2936                    .unwrap(),
2937                0
2938            );
2939            result
2940        };
2941        assert_eq!(result.bytes, bytes);
2942
2943        let mut client =
2944            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2945        assert_eq!(
2946            client
2947                .fetch_blob_bytes(&mut transport, "not json")
2948                .unwrap_err(),
2949            (
2950                "client.failed".to_owned(),
2951                "blob ref is not JSON".to_owned()
2952            )
2953        );
2954    }
2955
2956    #[test]
2957    fn visible_blob_references_survive_cache_trimming() {
2958        let schema = json!({"version":1,"tables":[
2959            {"name":"attachments","primaryKey":"id","columns":[
2960                {"name":"id","type":"string","nullable":false},
2961                {"name":"body","type":"blob_ref","nullable":true},
2962                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2963            {"name":"avatars","primaryKey":"id","columns":[
2964                {"name":"id","type":"string","nullable":false},
2965                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2966        ]});
2967        let bytes = b"referenced above cap".to_vec();
2968        let blob_id = blob_id_for(&bytes);
2969        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2970        let mut client = SyncClient::new(
2971            "visible-blob-refs".to_owned(),
2972            &schema,
2973            ClientLimits {
2974                blob_cache_max_bytes: Some(1),
2975                ..Default::default()
2976            },
2977        )
2978        .unwrap();
2979        client.conn.execute(
2980            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2981            rusqlite::params![reference, reference, reference, reference],
2982        ).unwrap();
2983        client
2984            .conn
2985            .execute(
2986                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2987                rusqlite::params![reference],
2988            )
2989            .unwrap();
2990        client.conn.execute(
2991            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2992            [],
2993        ).unwrap();
2994        let mut transport = CountingRealtimeTransport {
2995            blob_download: Some(bytes.clone()),
2996            ..Default::default()
2997        };
2998
2999        assert_eq!(
3000            client
3001                .fetch_blob_bytes(&mut transport, &blob_id)
3002                .unwrap()
3003                .bytes,
3004            bytes
3005        );
3006        let rows = client
3007            .conn
3008            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
3009            .unwrap()
3010            .query_map([], |row| row.get::<_, String>(0))
3011            .unwrap()
3012            .collect::<Result<Vec<_>, _>>()
3013            .unwrap();
3014        assert_eq!(rows, vec![blob_id]);
3015    }
3016
3017    #[test]
3018    fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
3019        let mut transport = CountingRealtimeTransport::default();
3020        let path = std::env::temp_dir().join(format!(
3021            "syncular-transport-gate-{}.db",
3022            uuid::Uuid::new_v4()
3023        ));
3024        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
3025            {"name":"id","type":"string","nullable":false},
3026            {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
3027        let mut client = SyncClient::open_path(
3028            "gate".into(),
3029            &schema,
3030            ClientLimits::default(),
3031            path.to_str().unwrap(),
3032        )
3033        .unwrap();
3034        client.connect_realtime(&mut transport).unwrap();
3035        client.set_transport_enabled(&mut transport, false);
3036        assert_eq!(transport.closes, 1);
3037        client.begin_security_preflight();
3038        client.activate_security(Default::default()).unwrap();
3039        client
3040            .subscribe(
3041                "tasks".into(),
3042                "tasks".into(),
3043                vec![("project_id".into(), vec!["p1".into()])],
3044                None,
3045            )
3046            .unwrap();
3047        for id in ["one", "two"] {
3048            client
3049                .mutate(vec![Mutation::Upsert {
3050                    table: "tasks".into(),
3051                    values: Map::from_iter([
3052                        ("id".into(), json!(id)),
3053                        ("project_id".into(), json!("p1")),
3054                    ]),
3055                    base_version: None,
3056                }])
3057                .unwrap();
3058        }
3059        assert_eq!(
3060            client
3061                .query("SELECT id FROM tasks ORDER BY id", &[])
3062                .unwrap()
3063                .len(),
3064            2
3065        );
3066        assert!(
3067            matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
3068        );
3069        assert!(client
3070            .connect_realtime(&mut transport)
3071            .unwrap_err()
3072            .starts_with("sync.offline:"));
3073        assert!(client
3074            .set_presence(&mut transport, "project:p1", None)
3075            .unwrap_err()
3076            .starts_with("sync.offline:"));
3077        assert_eq!(transport.sync_calls, 0);
3078        assert_eq!(transport.realtime_sync_calls, 0);
3079        assert_eq!(transport.connects, 1);
3080        assert!(transport.messages.is_empty());
3081        assert!(matches!(
3082            client.drain_sync_intents().as_slice(),
3083            [SyncIntent::None]
3084        ));
3085        let pending = client.pending_commit_ids();
3086        drop(client);
3087        let mut reopened = SyncClient::open_path(
3088            "gate".into(),
3089            &schema,
3090            ClientLimits::default(),
3091            path.to_str().unwrap(),
3092        )
3093        .unwrap();
3094        assert!(reopened.transport_enabled());
3095        reopened.set_transport_enabled(&mut transport, false);
3096        assert_eq!(reopened.pending_commit_ids(), pending);
3097        assert_eq!(
3098            reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
3099            2
3100        );
3101        reopened.set_transport_enabled(&mut transport, true);
3102        assert!(matches!(
3103            reopened.drain_sync_intents().as_slice(),
3104            [SyncIntent::Interactive]
3105        ));
3106        reopened.set_transport_enabled(&mut transport, true);
3107        assert!(reopened.drain_sync_intents().is_empty());
3108        drop(reopened);
3109        std::fs::remove_file(path).unwrap();
3110    }
3111
3112    #[test]
3113    fn realtime_connection_ownership_is_idempotent() {
3114        let mut client = client();
3115        let mut transport = CountingRealtimeTransport {
3116            connects: 0,
3117            closes: 0,
3118            ..Default::default()
3119        };
3120        client
3121            .connect_realtime(&mut transport)
3122            .expect("first connect");
3123        client
3124            .connect_realtime(&mut transport)
3125            .expect("idempotent connect");
3126        assert_eq!(transport.connects, 1);
3127        client.disconnect_realtime(&mut transport);
3128        client.disconnect_realtime(&mut transport);
3129        assert_eq!(transport.closes, 1);
3130        client
3131            .connect_realtime(&mut transport)
3132            .expect("deliberate reconnect");
3133        assert_eq!(transport.connects, 2);
3134    }
3135
3136    #[test]
3137    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
3138        let mut client = client();
3139        client
3140            .set_realtime_policy(RealtimePolicy::Required)
3141            .expect("set policy");
3142        let mut transport = CountingRealtimeTransport::default();
3143        match client.sync(&mut transport) {
3144            SyncOutcome::RealtimeUnavailable {
3145                state,
3146                reason_code,
3147                retry_delay_ms,
3148            } => {
3149                assert_eq!(state, RealtimeState::Disconnected);
3150                assert_eq!(reason_code, None);
3151                assert_eq!(retry_delay_ms, 250);
3152            }
3153            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3154        }
3155        assert_eq!(transport.sync_calls, 0);
3156        assert!(matches!(
3157            client.drain_sync_intents().as_slice(),
3158            [SyncIntent::Background { delay_ms: 250 }]
3159        ));
3160        let snapshot = client
3161            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3162            .expect("diagnostics");
3163        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3164        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
3165        assert_eq!(snapshot.host.realtime_reason_code, None);
3166        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3167        assert_eq!(
3168            snapshot
3169                .last_round
3170                .as_ref()
3171                .and_then(|round| round.error_code.clone()),
3172            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
3173        );
3174    }
3175
3176    #[test]
3177    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
3178        let mut client = client();
3179        client
3180            .set_realtime_policy(RealtimePolicy::Required)
3181            .expect("set policy");
3182        let mut transport = CountingRealtimeTransport {
3183            refuse_connect: true,
3184            ..Default::default()
3185        };
3186        assert!(client.connect_realtime(&mut transport).is_err());
3187        assert_eq!(client.realtime_state(), RealtimeState::Refused);
3188        let snapshot = client
3189            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3190            .expect("diagnostics");
3191        assert_eq!(
3192            snapshot.host.realtime_reason_code.as_deref(),
3193            Some("transport.failed")
3194        );
3195        match client.sync(&mut transport) {
3196            SyncOutcome::RealtimeUnavailable {
3197                state, reason_code, ..
3198            } => {
3199                assert_eq!(state, RealtimeState::Refused);
3200                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
3201            }
3202            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3203        }
3204        assert_eq!(transport.sync_calls, 0);
3205        assert_eq!(transport.realtime_sync_calls, 0);
3206    }
3207
3208    #[test]
3209    fn an_unsolicited_socket_end_is_lost_with_the_transport_code_and_a_retry_intent() {
3210        let mut client = client();
3211        let mut transport = CountingRealtimeTransport::default();
3212        let ended = TransportError::new("transport.failed", "realtime connection closed");
3213        client.realtime_lost(&mut transport, &ended);
3214        assert_eq!(
3215            client.realtime_state(),
3216            RealtimeState::Disconnected,
3217            "a socket that was never connected has nothing to lose"
3218        );
3219        assert!(client.drain_sync_intents().is_empty());
3220
3221        client.connect_realtime(&mut transport).expect("connect");
3222        client.realtime_lost(&mut transport, &ended);
3223        assert_eq!(client.realtime_state(), RealtimeState::Lost);
3224        assert_eq!(transport.closes, 1);
3225        assert!(matches!(
3226            client.drain_sync_intents().as_slice(),
3227            [SyncIntent::Background { delay_ms: 250 }]
3228        ));
3229        let snapshot = client
3230            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3231            .expect("diagnostics");
3232        assert_eq!(snapshot.host.realtime, RealtimeState::Lost);
3233        assert_eq!(
3234            snapshot.host.realtime_reason_code.as_deref(),
3235            Some("transport.failed")
3236        );
3237        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3238
3239        client.realtime_lost(&mut transport, &ended);
3240        assert!(
3241            client.drain_sync_intents().is_empty(),
3242            "a second report for the same loss schedules nothing"
3243        );
3244        client.connect_realtime(&mut transport).expect("reconnect");
3245        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3246    }
3247
3248    #[test]
3249    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
3250        let mut client = client();
3251        client
3252            .set_realtime_policy(RealtimePolicy::Required)
3253            .expect("set policy");
3254        let mut transport = CountingRealtimeTransport::default();
3255        client.connect_realtime(&mut transport).expect("connect");
3256        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3257        assert!(
3258            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
3259            "a policy change cannot silently re-route a connected socket"
3260        );
3261        assert!(matches!(
3262            client.sync(&mut transport),
3263            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
3264        ));
3265        assert_eq!(client.realtime_state(), RealtimeState::Lost);
3266        assert_eq!(transport.realtime_sync_calls, 1);
3267        assert_eq!(transport.sync_calls, 0);
3268        let snapshot = client
3269            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3270            .expect("diagnostics");
3271        assert_eq!(
3272            snapshot.host.realtime_reason_code.as_deref(),
3273            Some("sync.transport_failed")
3274        );
3275        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3276        match client.sync(&mut transport) {
3277            SyncOutcome::RealtimeUnavailable {
3278                state, reason_code, ..
3279            } => {
3280                assert_eq!(state, RealtimeState::Lost);
3281                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
3282            }
3283            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3284        }
3285        assert_eq!(transport.sync_calls, 0);
3286        client.disconnect_realtime(&mut transport);
3287        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
3288        assert_eq!(transport.closes, 1);
3289        client.connect_realtime(&mut transport).expect("reconnect");
3290        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3291        assert_eq!(transport.connects, 2);
3292    }
3293
3294    #[test]
3295    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
3296        let mut client = client();
3297        client
3298            .set_realtime_policy(RealtimePolicy::Off)
3299            .expect("set policy");
3300        let mut transport = CountingRealtimeTransport::default();
3301        assert!(client.connect_realtime(&mut transport).is_err());
3302        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
3303        assert!(matches!(
3304            client.sync(&mut transport),
3305            SyncOutcome::Failed { .. }
3306        ));
3307        assert_eq!(transport.sync_calls, 1);
3308        assert_eq!(transport.realtime_sync_calls, 0);
3309        let snapshot = client
3310            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3311            .expect("diagnostics");
3312        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
3313        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
3314    }
3315
3316    #[test]
3317    fn optional_policy_keeps_the_http_round() {
3318        let mut client = client();
3319        let mut transport = CountingRealtimeTransport::default();
3320        assert!(matches!(
3321            client.sync(&mut transport),
3322            SyncOutcome::Failed { .. }
3323        ));
3324        assert_eq!(transport.sync_calls, 1);
3325        assert_eq!(transport.realtime_sync_calls, 0);
3326        let snapshot = client
3327            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3328            .expect("diagnostics");
3329        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3330        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
3331    }
3332
3333    #[test]
3334    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
3335        let mut client = client();
3336        client
3337            .subscribe(
3338                "repair-tasks".to_owned(),
3339                "tasks".to_owned(),
3340                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3341                None,
3342            )
3343            .expect("subscribe");
3344        {
3345            let sub = client
3346                .subs
3347                .iter_mut()
3348                .find(|sub| sub.id == "repair-tasks")
3349                .expect("subscription");
3350            sub.cursor = 42;
3351            sub.synced_once = true;
3352            let persisted = sub.clone();
3353            client.persist_sub(&persisted).unwrap();
3354        }
3355        for table in ["_syncular_base_tasks", "tasks"] {
3356            client
3357                .conn
3358                .execute(
3359                    &format!(
3360                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
3361                    ),
3362                    rusqlite::params!["server-row", "p1"],
3363                )
3364                .expect("seed server row");
3365        }
3366        let pending = client
3367            .mutate(vec![Mutation::Upsert {
3368                table: "tasks".to_owned(),
3369                values: Map::from_iter([
3370                    ("id".to_owned(), Value::from("offline-row")),
3371                    ("project_id".to_owned(), Value::from("p1")),
3372                ]),
3373                base_version: None,
3374            }])
3375            .expect("queue offline work");
3376        client.drain_change_batches();
3377        client.drain_sync_intents();
3378
3379        assert_eq!(
3380            client
3381                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3382                    rebootstrap_id: "support-case-001".to_owned(),
3383                })
3384                .expect("rebootstrap"),
3385            LocalDataRebootstrapResult {
3386                already_applied: false,
3387                retained_commits: 1,
3388                reset_subscriptions: 1,
3389            }
3390        );
3391        let visible_ids = client
3392            .conn
3393            .prepare("SELECT id FROM tasks ORDER BY id")
3394            .expect("prepare visible ids")
3395            .query_map([], |row| row.get::<_, String>(0))
3396            .expect("query visible ids")
3397            .collect::<Result<Vec<_>, _>>()
3398            .expect("collect visible ids");
3399        assert_eq!(visible_ids, vec!["offline-row"]);
3400        assert_eq!(client.pending_commit_ids(), vec![pending]);
3401        assert_eq!(
3402            client
3403                .subscription_state("repair-tasks")
3404                .expect("subscription")
3405                .cursor,
3406            -1
3407        );
3408        assert!(client.upgrading());
3409        assert!(client.sync_needed());
3410        assert!(matches!(
3411            client.drain_sync_intents().as_slice(),
3412            [SyncIntent::Interactive]
3413        ));
3414        assert_eq!(client.drain_change_batches().len(), 1);
3415
3416        assert_eq!(
3417            client
3418                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3419                    rebootstrap_id: "support-case-001".to_owned(),
3420                })
3421                .expect("idempotent retry"),
3422            LocalDataRebootstrapResult {
3423                already_applied: true,
3424                retained_commits: 1,
3425                reset_subscriptions: 1,
3426            }
3427        );
3428        assert!(client.drain_change_batches().is_empty());
3429    }
3430
3431    #[test]
3432    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
3433        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
3434        assert_eq!(
3435            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
3436            (3, 4)
3437        );
3438        assert_eq!(
3439            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
3440            (0, 0)
3441        );
3442        for malformed in [
3443            "",
3444            "{}",
3445            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
3446            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
3447            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
3448        ] {
3449            assert_eq!(
3450                decode_local_rebootstrap_receipt(malformed)
3451                    .expect_err("malformed receipt must fail"),
3452                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3453            );
3454        }
3455    }
3456
3457    #[test]
3458    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
3459        let path = std::env::temp_dir().join(format!(
3460            "syncular-rebootstrap-receipt-{}.db",
3461            uuid::Uuid::new_v4()
3462        ));
3463        let schema = json!({
3464            "version": 1,
3465            "tables": [{
3466                "name": "tasks",
3467                "primaryKey": "id",
3468                "columns": [
3469                    { "name": "id", "type": "string", "nullable": false },
3470                    { "name": "project_id", "type": "string", "nullable": false }
3471                ],
3472                "scopes": [{ "pattern": "project:{project_id}" }]
3473            }]
3474        });
3475        let path_string = path.to_str().expect("UTF-8 temp path");
3476
3477        {
3478            let mut first = SyncClient::open_path(
3479                "repair-restart-client".to_owned(),
3480                &schema,
3481                ClientLimits::default(),
3482                path_string,
3483            )
3484            .expect("first open");
3485            first
3486                .subscribe(
3487                    "repair-tasks".to_owned(),
3488                    "tasks".to_owned(),
3489                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3490                    None,
3491                )
3492                .expect("subscribe");
3493            first
3494                .mutate(vec![Mutation::Upsert {
3495                    table: "tasks".to_owned(),
3496                    values: Map::from_iter([
3497                        ("id".to_owned(), Value::from("offline-row")),
3498                        ("project_id".to_owned(), Value::from("p1")),
3499                    ]),
3500                    base_version: None,
3501                }])
3502                .expect("queue offline work");
3503            assert_eq!(
3504                first
3505                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
3506                        rebootstrap_id: "restart-receipt".to_owned(),
3507                    })
3508                    .expect("first rebootstrap"),
3509                LocalDataRebootstrapResult {
3510                    already_applied: false,
3511                    retained_commits: 1,
3512                    reset_subscriptions: 1,
3513                }
3514            );
3515        }
3516
3517        let mut reopened = SyncClient::open_path(
3518            "repair-restart-client".to_owned(),
3519            &schema,
3520            ClientLimits::default(),
3521            path_string,
3522        )
3523        .expect("reopen");
3524        reopened.drain_change_batches();
3525        reopened.drain_sync_intents();
3526        assert_eq!(
3527            reopened
3528                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3529                    rebootstrap_id: "restart-receipt".to_owned(),
3530                })
3531                .expect("receipt replay"),
3532            LocalDataRebootstrapResult {
3533                already_applied: true,
3534                retained_commits: 1,
3535                reset_subscriptions: 1,
3536            }
3537        );
3538        assert!(reopened.drain_change_batches().is_empty());
3539        assert!(reopened.drain_sync_intents().is_empty());
3540        drop(reopened);
3541        std::fs::remove_file(path).expect("remove temp database");
3542    }
3543
3544    #[test]
3545    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
3546        let mut malformed = client();
3547        malformed
3548            .subscribe(
3549                "repair-tasks".to_owned(),
3550                "tasks".to_owned(),
3551                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3552                None,
3553            )
3554            .expect("subscribe");
3555        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
3556        malformed.drain_change_batches();
3557        malformed.drain_sync_intents();
3558        let malformed_error = malformed
3559            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3560                rebootstrap_id: "malformed".to_owned(),
3561            })
3562            .expect_err("malformed receipt must fail closed");
3563        assert_eq!(
3564            malformed_error,
3565            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3566        );
3567        assert!(!malformed.upgrading());
3568        assert_eq!(
3569            malformed
3570                .subscription_state("repair-tasks")
3571                .expect("unchanged subscription")
3572                .cursor,
3573            -1
3574        );
3575        assert!(malformed.drain_change_batches().is_empty());
3576        assert!(malformed.drain_sync_intents().is_empty());
3577
3578        let mut unreadable = client();
3579        unreadable
3580            .conn
3581            .execute(
3582                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
3583                rusqlite::params!["server-row", "p1"],
3584            )
3585            .expect("seed visible row");
3586        unreadable
3587            .conn
3588            .execute("DROP TABLE _syncular_meta", [])
3589            .expect("break marker storage");
3590        let unreadable_error = unreadable
3591            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3592                rebootstrap_id: "unreadable".to_owned(),
3593            })
3594            .expect_err("unreadable marker storage must fail closed");
3595        assert_eq!(
3596            unreadable_error,
3597            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
3598        );
3599        let visible_rows = unreadable
3600            .conn
3601            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
3602            .expect("visible projection remains");
3603        assert_eq!(visible_rows, 1);
3604        assert!(!unreadable.upgrading());
3605        assert!(unreadable.drain_change_batches().is_empty());
3606        assert!(unreadable.drain_sync_intents().is_empty());
3607    }
3608
3609    #[test]
3610    fn local_rebootstrap_cannot_bypass_schema_floor() {
3611        let mut client = client();
3612        client.set_schema_floor(Some(SchemaFloor {
3613            required_schema_version: Some(2),
3614            latest_schema_version: Some(2),
3615        }));
3616        let error = client
3617            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3618                rebootstrap_id: "blocked-floor".to_owned(),
3619            })
3620            .expect_err("schema floor must block repair");
3621        assert!(error.contains("cannot bypass an active schema-floor stop"));
3622    }
3623
3624    #[test]
3625    fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
3626        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3627            "columns":[{"name":"id","type":"string","nullable":false},
3628                {"name":"project_id","type":"string","nullable":false},
3629                {"name":"starts_at_ms","type":"integer","nullable":false},
3630                {"name":"version","type":"integer","nullable":false}],
3631            "scopes":[{"pattern":"project:{project_id}"}]}]});
3632        let mut client =
3633            SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
3634        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3635        client
3636            .write_base_row(
3637                "tasks",
3638                &vec![
3639                    Some(ColumnValue::String("t1".into())),
3640                    Some(ColumnValue::String("p1".into())),
3641                    Some(ColumnValue::Integer(100)),
3642                    Some(ColumnValue::Integer(1)),
3643                ],
3644                1,
3645            )
3646            .unwrap();
3647        client.rebuild_overlay().expect("rebuild overlay");
3648        let first = client
3649            .patch(
3650                "tasks",
3651                "t1",
3652                Map::from_iter([
3653                    ("starts_at_ms".into(), json!(200)),
3654                    ("version".into(), json!(2)),
3655                ]),
3656                None,
3657            )
3658            .unwrap();
3659        let (_, first_request) = client.build_request(false).unwrap();
3660        // This second edit happens while the first request is in flight.
3661        let second = client
3662            .patch(
3663                "tasks",
3664                "t1",
3665                Map::from_iter([
3666                    ("starts_at_ms".into(), json!(300)),
3667                    ("version".into(), json!(3)),
3668                ]),
3669                None,
3670            )
3671            .unwrap();
3672        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3673        for (id, seq, meta) in [
3674            (first, 2, first_request),
3675            (second, 3, client.build_request(false).unwrap().1),
3676        ] {
3677            let response = Message {
3678                wire_version: WIRE_VERSION,
3679                msg_kind: MsgKind::Response,
3680                frames: vec![
3681                    Frame::RespHeader {
3682                        required_schema_version: None,
3683                        latest_schema_version: None,
3684                        log_epoch: Some("epoch-1".into()),
3685                        reset_required: Some(false),
3686                    },
3687                    Frame::PushResult {
3688                        client_commit_id: id,
3689                        status: PushStatus::Applied,
3690                        commit_seq: Some(seq),
3691                        results: vec![OpResult::Applied { op_index: 0 }],
3692                    },
3693                ],
3694            };
3695            assert!(matches!(
3696                client.process_response(&mut transport, response, &meta),
3697                SyncOutcome::Ok(_)
3698            ));
3699            let row = &client
3700                .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
3701                .unwrap()[0];
3702            assert_eq!(
3703                row["starts_at_ms"],
3704                json!(300),
3705                "an applied ACK without a pull image must not expose the old base"
3706            );
3707            assert_eq!(
3708                row["version"],
3709                json!(3),
3710                "the queued domain version must remain monotonic"
3711            );
3712        }
3713    }
3714
3715    #[test]
3716    fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
3717        for stored in [None, Some("epoch-old")] {
3718            let mut client = client();
3719            client
3720                .subscribe(
3721                    "tasks".into(),
3722                    "tasks".into(),
3723                    vec![("project_id".into(), vec!["p1".into()])],
3724                    None,
3725                )
3726                .unwrap();
3727            client
3728                .conn
3729                .execute_batch(
3730                    "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
3731                )
3732                .unwrap();
3733            let id = client
3734                .mutate(vec![Mutation::Upsert {
3735                    table: "tasks".into(),
3736                    values: Map::from_iter([
3737                        ("id".into(), json!("offline")),
3738                        ("project_id".into(), json!("p1")),
3739                    ]),
3740                    base_version: None,
3741                }])
3742                .unwrap();
3743            if let Some(epoch) = stored {
3744                client.set_meta(LOG_EPOCH_KEY, epoch);
3745            }
3746            let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
3747            let (_, meta) = client.build_request(false).unwrap();
3748            client.drain_change_batches();
3749            client.drain_sync_intents();
3750            let response = Message {
3751                wire_version: WIRE_VERSION,
3752                msg_kind: MsgKind::Response,
3753                frames: vec![Frame::RespHeader {
3754                    required_schema_version: None,
3755                    latest_schema_version: None,
3756                    log_epoch: Some("epoch-new".into()),
3757                    reset_required: Some(true),
3758                }],
3759            };
3760            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3761            let outcome = client.process_response(&mut transport, response, &meta);
3762            let SyncOutcome::Ok(report) = outcome else {
3763                panic!("epoch round failed: {outcome:?}");
3764            };
3765            assert_eq!(
3766                report.resets,
3767                if stored.is_none() {
3768                    Vec::<String>::new()
3769                } else {
3770                    vec!["tasks".into()]
3771                }
3772            );
3773            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3774            assert_eq!(client.pending_commit_ids(), vec![id]);
3775            assert_eq!(
3776                client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3777                json!("offline")
3778            );
3779            let trigger: i64 = client
3780                .conn
3781                .query_row(
3782                    "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3783                    [],
3784                    |row| row.get(0),
3785                )
3786                .unwrap();
3787            if stored.is_none() {
3788                assert!(!client.upgrading);
3789                assert!(client
3790                    .drain_change_batches()
3791                    .iter()
3792                    .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3793                assert_eq!(
3794                    serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3795                    before
3796                );
3797                assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3798            } else {
3799                assert!(client.upgrading);
3800                assert_eq!(trigger, 0, "an epoch change must drop the old table");
3801            }
3802            assert!(matches!(
3803                client.drain_sync_intents().as_slice(),
3804                [SyncIntent::Interactive]
3805            ));
3806        }
3807    }
3808
3809    #[test]
3810    fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3811        for key in ["logEpoch", "localRevision"] {
3812            let mut client = client();
3813            let id = client
3814                .mutate(vec![Mutation::Upsert {
3815                    table: "tasks".into(),
3816                    values: Map::from_iter([
3817                        ("id".into(), json!("offline")),
3818                        ("project_id".into(), json!("p1")),
3819                    ]),
3820                    base_version: None,
3821                }])
3822                .unwrap();
3823            let revision = client.local_revision();
3824            let needed = client.sync_needed;
3825            client.drain_change_batches();
3826            client.drain_sync_intents();
3827            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();
3828            assert!(client.run_log_epoch_reset("epoch-new").is_err());
3829            assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3830            assert!(!client.upgrading);
3831            assert_eq!(client.sync_needed, needed);
3832            assert_eq!(client.local_revision(), revision);
3833            assert_eq!(client.pending_commit_ids(), [id]);
3834            assert!(client.drain_change_batches().is_empty());
3835            assert!(client.drain_sync_intents().is_empty());
3836        }
3837    }
3838
3839    #[test]
3840    fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3841        for key in [
3842            "localSchemaVersion",
3843            "localSchemaDescriptor",
3844            "logEpoch",
3845            "localRevision",
3846        ] {
3847            let mut client = client();
3848            client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3849            let commit = client
3850                .mutate(vec![Mutation::Upsert {
3851                    table: "tasks".into(),
3852                    values: Map::from_iter([
3853                        ("id".into(), json!("offline")),
3854                        ("project_id".into(), json!("p1")),
3855                    ]),
3856                    base_version: None,
3857                }])
3858                .unwrap();
3859            client.active_round = Some(uuid::Uuid::new_v4());
3860            client.overlay_dirty.set(false);
3861            let round = client.active_round;
3862            let needed = client.sync_needed;
3863            let revision = client.local_revision();
3864            let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3865            client.drain_change_batches();
3866            client.drain_sync_intents();
3867            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();
3868            assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3869            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3870            assert!(!client.upgrading);
3871            assert_eq!(client.active_round, round);
3872            assert!(!client.overlay_dirty.get());
3873            assert_eq!(client.sync_needed, needed);
3874            assert_eq!(client.local_revision(), revision);
3875            assert_eq!(
3876                client.pending_commit_ids().as_slice(),
3877                std::slice::from_ref(&commit)
3878            );
3879            assert_eq!(
3880                serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3881                rows
3882            );
3883            assert!(client.drain_change_batches().is_empty());
3884            assert!(client.drain_sync_intents().is_empty());
3885            assert!(client.conn.is_autocommit());
3886            client
3887                .conn
3888                .execute_batch("DROP TRIGGER fail_reset")
3889                .unwrap();
3890            client.run_log_epoch_reset("epoch-new").unwrap();
3891            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3892            assert_eq!(client.pending_commit_ids(), [commit]);
3893        }
3894    }
3895
3896    #[test]
3897    fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3898        for subscriptions in [0, 2, 6, 12] {
3899            let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3900                { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3901                { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3902            ] }), ClientLimits::default()).unwrap();
3903            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3904            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();
3905            for index in 0..32 {
3906                client
3907                    .mutate(vec![Mutation::Upsert {
3908                        table: "tasks".into(),
3909                        values: Map::from_iter([
3910                            ("id".into(), json!(format!("task-{index}"))),
3911                            ("project_id".into(), json!("p1")),
3912                        ]),
3913                        base_version: None,
3914                    }])
3915                    .unwrap();
3916            }
3917            let (_, meta) = client.build_request(false).unwrap();
3918            let mut frames = vec![Frame::RespHeader {
3919                required_schema_version: None,
3920                latest_schema_version: None,
3921                log_epoch: Some("epoch-1".into()),
3922                reset_required: Some(false),
3923            }];
3924            frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3925                Frame::PushResult {
3926                    client_commit_id: id.clone(),
3927                    status: PushStatus::Applied,
3928                    commit_seq: Some(index as i64 + 1),
3929                    results: vec![OpResult::Applied { op_index: 0 }],
3930                }
3931            }));
3932            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3933            client.acknowledged_replay_count.set(0);
3934            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3935            assert!(matches!(
3936                client.process_response(
3937                    &mut transport,
3938                    Message {
3939                        wire_version: WIRE_VERSION,
3940                        msg_kind: MsgKind::Response,
3941                        frames
3942                    },
3943                    &meta
3944                ),
3945                SyncOutcome::Ok(_)
3946            ));
3947            assert_eq!(client.acknowledged_replay_count.get(), 32);
3948            assert_eq!(
3949                client
3950                    .query(
3951                        "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3952                        &[]
3953                    )
3954                    .unwrap()[0]["n"],
3955                json!(32)
3956            );
3957            assert_eq!(
3958                client
3959                    .query(
3960                        "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3961                        &[]
3962                    )
3963                    .unwrap()[0]["n"],
3964                json!(32)
3965            );
3966            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3967            client.acknowledged_replay_count.set(0);
3968            for index in 0..subscriptions {
3969                client
3970                    .subscribe(
3971                        format!("docs-{index}"),
3972                        "docs".into(),
3973                        vec![("project_id".into(), vec![format!("scope-{index}")])],
3974                        None,
3975                    )
3976                    .unwrap();
3977                let sub = client.subs.len() - 1;
3978                client.subs[sub].effective =
3979                    Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3980                let segment = RowsSegment {
3981                    table: "docs".into(),
3982                    schema_version: 1,
3983                    columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3984                    blocks: if index % 2 == 0 {
3985                        vec![vec![ssp2::segment::SegmentRow {
3986                            values: vec![
3987                                Some(ColumnValue::String(format!("doc-{index}"))),
3988                                Some(ColumnValue::String(format!("scope-{index}"))),
3989                            ],
3990                            server_version: 1,
3991                        }]]
3992                    } else {
3993                        vec![]
3994                    },
3995                };
3996                assert!(client.apply_segment(sub, &segment, true).is_ok());
3997            }
3998            assert_eq!(
3999                client
4000                    .query("SELECT count(*) AS n FROM ack_writes", &[])
4001                    .unwrap()[0]["n"],
4002                json!(0),
4003                "unrelated bootstrap must not restore/replay tasks"
4004            );
4005            assert_eq!(
4006                client.acknowledged_replay_count.get(),
4007                0,
4008                "unrelated bootstrap must not decode task ACKs"
4009            );
4010            assert_eq!(
4011                client
4012                    .query("SELECT count(*) AS n FROM tasks", &[])
4013                    .unwrap()[0]["n"],
4014                json!(32)
4015            );
4016            assert_eq!(
4017                client
4018                    .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
4019                    .unwrap()[0]["n"],
4020                json!(32)
4021            );
4022        }
4023    }
4024
4025    #[test]
4026    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
4027        let mut client = client();
4028        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4029        const COMMIT_COUNT: usize = 32;
4030
4031        for index in 0..COMMIT_COUNT {
4032            client
4033                .mutate(vec![Mutation::Upsert {
4034                    table: "tasks".to_owned(),
4035                    values: Map::from_iter([
4036                        ("id".to_owned(), Value::from(format!("task-{index}"))),
4037                        ("project_id".to_owned(), Value::from("project-1")),
4038                    ]),
4039                    base_version: None,
4040                }])
4041                .expect("queue commit");
4042        }
4043
4044        let (_, request_meta) = client.build_request(false).unwrap();
4045        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
4046        let mut frames = vec![Frame::RespHeader {
4047            required_schema_version: None,
4048            latest_schema_version: None,
4049            log_epoch: Some("epoch-1".to_owned()),
4050            reset_required: Some(false),
4051        }];
4052        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
4053            |(index, client_commit_id)| Frame::PushResult {
4054                client_commit_id: client_commit_id.clone(),
4055                status: PushStatus::Applied,
4056                commit_seq: Some(index as i64 + 1),
4057                results: vec![OpResult::Applied { op_index: 0 }],
4058            },
4059        ));
4060        let response = Message {
4061            wire_version: WIRE_VERSION,
4062            msg_kind: MsgKind::Response,
4063            frames,
4064        };
4065        let mut transport =
4066            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
4067
4068        client.overlay_rebuild_count.set(0);
4069        client.outcome_prune_count.set(0);
4070        let outcome = client.process_response(&mut transport, response, &request_meta);
4071        assert!(matches!(outcome, SyncOutcome::Ok(_)));
4072        assert!(client.pending_commit_ids().is_empty());
4073        assert_eq!(
4074            client.overlay_rebuild_count.get(),
4075            1,
4076            "one response must reconcile its acknowledged commits with one overlay rebuild"
4077        );
4078        assert_eq!(
4079            client.outcome_prune_count.get(),
4080            1,
4081            "one response must enforce outcome retention once"
4082        );
4083    }
4084
4085    #[test]
4086    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
4087        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
4088            "columns": [
4089                {"name":"id", "type":"string", "nullable":false},
4090                {"name":"project_id", "type":"string", "nullable":false},
4091                {"name":"title", "type":"string", "nullable":false}
4092            ], "scopes": [{"pattern":"project:{project_id}"}],
4093            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4094            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
4095        }] });
4096        let mut incremental =
4097            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
4098        let mut reference =
4099            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
4100        let tables = vec!["tasks".to_owned()];
4101        for index in 0..32 {
4102            let id = format!("task-{}", index % 8);
4103            let values = Map::from_iter([
4104                ("id".to_owned(), Value::from(id.clone())),
4105                (
4106                    "project_id".to_owned(),
4107                    Value::from(format!("p{}", index % 2)),
4108                ),
4109                ("title".to_owned(), Value::from(format!("needle{index}"))),
4110            ]);
4111            let payload = encode_row_json(
4112                incremental.schema.table("tasks").unwrap(),
4113                &id,
4114                &values,
4115                &incremental.encryption,
4116            )
4117            .unwrap();
4118            let change = ssp2::model::Change {
4119                table_index: 0,
4120                row_id: id,
4121                op: if index % 5 == 0 {
4122                    Op::Delete
4123                } else {
4124                    Op::Upsert
4125                },
4126                row_version: Some(index + 1),
4127                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
4128                row: Some(payload),
4129            };
4130            reference.overlay_dirty.set(true);
4131            for instance in [&mut incremental, &mut reference] {
4132                instance.begin_observation("mirror_test").unwrap();
4133                let mut batch = ChangeAccumulator::default();
4134                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
4135                instance
4136                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
4137                    .unwrap();
4138                instance
4139                    .rebuild_overlay_if_dirty()
4140                    .expect("rebuild overlay");
4141                instance.finish_observation("mirror_test", batch).unwrap();
4142            }
4143            for sql in [
4144                "SELECT * FROM tasks ORDER BY id",
4145                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4146            ] {
4147                assert_eq!(
4148                    incremental.query(sql, &[]).unwrap(),
4149                    reference.query(sql, &[]).unwrap()
4150                );
4151            }
4152            assert_eq!(incremental.local_revision(), reference.local_revision());
4153            assert_eq!(
4154                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4155                serde_json::to_value(reference.drain_change_batches()).unwrap()
4156            );
4157        }
4158        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4159        assert_eq!(reference.overlay_rebuild_count.get(), 32);
4160    }
4161
4162    #[test]
4163    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
4164        let cases = [
4165            (
4166                "string",
4167                vec![
4168                    json!("1"),
4169                    json!("01"),
4170                    json!(""),
4171                    json!("nul\u{0}id"),
4172                    json!("é"),
4173                ],
4174            ),
4175            (
4176                "integer",
4177                vec![
4178                    json!(i64::MIN),
4179                    json!(-1),
4180                    json!(0),
4181                    json!(1),
4182                    json!(i64::MAX),
4183                ],
4184            ),
4185            ("boolean", vec![json!(false), json!(true)]),
4186            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
4187        ];
4188        for (kind, ids) in cases {
4189            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4190                "columns":[{"name":"id","type":kind,"nullable":false},
4191                    {"name":"project_id","type":"string","nullable":false}],
4192                "scopes":[{"pattern":"project:{project_id}"}]}]});
4193            let mut client =
4194                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
4195            for (index, id) in ids.iter().enumerate() {
4196                let table = client.schema.table("tasks").unwrap();
4197                client
4198                    .write_base_row(
4199                        "tasks",
4200                        &vec![
4201                            if kind == "integer" {
4202                                // Exercise local SQLite key lookup across its
4203                                // full i64 range independently of authoring.
4204                                Some(ColumnValue::Integer(id.as_i64().unwrap()))
4205                            } else {
4206                                json_to_column_value(&table.columns[0], Some(id)).unwrap()
4207                            },
4208                            Some(ColumnValue::String(format!("p{index}"))),
4209                        ],
4210                        1,
4211                    )
4212                    .unwrap();
4213            }
4214            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4215            for base in [true, false] {
4216                let full = if base {
4217                    base_table("tasks")
4218                } else {
4219                    visible_table("tasks")
4220                };
4221                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
4222                for row_id in [
4223                    "1",
4224                    "01",
4225                    "1.0",
4226                    "1.0000000000000002",
4227                    "+1",
4228                    "1e0",
4229                    " 1",
4230                    "-1",
4231                    "0",
4232                    "9223372036854775807",
4233                    "-9223372036854775808",
4234                    "true",
4235                    "false",
4236                    "",
4237                    "nul\u{0}id",
4238                    "é",
4239                    "null",
4240                    "{\"a\":1}",
4241                ] {
4242                    let old_sql =
4243                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
4244                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
4245                    let old = client
4246                        .conn
4247                        .prepare(&old_sql)
4248                        .unwrap()
4249                        .query_map([row_id], |row| row.get::<_, String>(0))
4250                        .unwrap()
4251                        .collect::<Result<Vec<_>, _>>()
4252                        .unwrap();
4253                    let new = client
4254                        .conn
4255                        .prepare_cached(&new_sql)
4256                        .unwrap()
4257                        .query_map([row_id], |row| row.get::<_, String>(0))
4258                        .unwrap()
4259                        .collect::<Result<Vec<_>, _>>()
4260                        .unwrap();
4261                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
4262                    let mut batch = ChangeAccumulator::default();
4263                    assert_eq!(
4264                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
4265                        !old.is_empty()
4266                    );
4267                    let expected = old.first().map(|scope| {
4268                        BTreeMap::from([(
4269                            "tasks".to_owned(),
4270                            Some(BTreeSet::from([format!("project:{scope}")])),
4271                        )])
4272                    });
4273                    assert_eq!(batch.tables, expected.unwrap_or_default());
4274                    client
4275                        .conn
4276                        .execute_batch("SAVEPOINT lookup_delete")
4277                        .unwrap();
4278                    if base {
4279                        client.delete_base_row("tasks", row_id).unwrap();
4280                    } else {
4281                        client
4282                            .apply_outbox_ops(&[OutboxOp {
4283                                upsert: false,
4284                                table: "tasks".into(),
4285                                row_id: row_id.into(),
4286                                base_version: None,
4287                                values: None,
4288                            }])
4289                            .expect("replay delete");
4290                    }
4291                    let remaining: i64 = client
4292                        .conn
4293                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
4294                            row.get(0)
4295                        })
4296                        .unwrap();
4297                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
4298                    client
4299                        .conn
4300                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
4301                        .unwrap();
4302                    client.overlay_dirty.set(false);
4303                }
4304                for (predicate, expected) in [
4305                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
4306                    (predicate, "SEARCH"),
4307                ] {
4308                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
4309                    let details = client
4310                        .conn
4311                        .prepare(&sql)
4312                        .unwrap()
4313                        .query_map(["1"], |row| row.get::<_, String>(3))
4314                        .unwrap()
4315                        .collect::<Result<Vec<_>, _>>()
4316                        .unwrap();
4317                    assert!(
4318                        details.iter().any(|detail| detail.contains(expected)),
4319                        "{kind}: {details:?}"
4320                    );
4321                }
4322            }
4323        }
4324    }
4325
4326    #[test]
4327    fn primary_keys_reject_every_ineligible_column_type() {
4328        // §2.4: a `rowId` is a string, so a primary key type whose string form
4329        // differs per renderer, or cannot be reproduced by local storage
4330        // comparison, is a schema error rather than a silent lookup miss.
4331        for ty in ["float", "bytes", "crdt", "blob_ref"] {
4332            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4333                "columns":[{"name":"id","type":ty,"nullable":false},
4334                    {"name":"project_id","type":"string","nullable":false}],
4335                "scopes":[{"pattern":"project:{project_id}"}]}]});
4336            let error =
4337                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
4338                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
4339                    Err(error) => error,
4340                };
4341            assert!(
4342                error.contains("has an ineligible column type (§2.4)"),
4343                "{ty}: {error}"
4344            );
4345        }
4346        for ty in ["string", "integer", "boolean", "json"] {
4347            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4348                "columns":[{"name":"id","type":ty,"nullable":false},
4349                    {"name":"project_id","type":"string","nullable":false}],
4350                "scopes":[{"pattern":"project:{project_id}"}]}]});
4351            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
4352                .unwrap_or_else(|error| panic!("{ty}: {error}"));
4353        }
4354    }
4355
4356    #[test]
4357    fn pending_row_reconciliation_matches_full_fifo_replay() {
4358        for unique in [false, true] {
4359            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
4360                "name": name, "primaryKey": "id", "columns": [
4361                    {"name":"id", "type":"string", "nullable":false},
4362                    {"name":"project_id", "type":"string", "nullable":false},
4363                    {"name":"title", "type":"string", "nullable":false}],
4364                "scopes": [{"pattern":"project:{project_id}"}],
4365                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
4366                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
4367            })))});
4368            let mut incremental =
4369                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4370            let mut reference =
4371                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4372            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
4373            for instance in [&mut incremental, &mut reference] {
4374                for table in &tables {
4375                    for index in 0..12 {
4376                        instance
4377                            .write_base_row(
4378                                table,
4379                                &vec![
4380                                    Some(ColumnValue::String(format!("row{index}"))),
4381                                    Some(ColumnValue::String("p0".into())),
4382                                    Some(ColumnValue::String(format!("base{index}"))),
4383                                ],
4384                                1,
4385                            )
4386                            .unwrap();
4387                    }
4388                }
4389                instance
4390                    .rebuild_overlay_if_dirty()
4391                    .expect("rebuild overlay");
4392            }
4393            for index in 0..80 {
4394                let mutations = (0..3)
4395                    .map(|offset| {
4396                        let table = tables[(index + offset) % 2].clone();
4397                        let id = format!("row{}", (index + offset) % 16);
4398                        if (index + offset) % 7 == 0 {
4399                            Mutation::Delete {
4400                                table,
4401                                row_id: id,
4402                                base_version: None,
4403                            }
4404                        } else {
4405                            Mutation::Upsert {
4406                                table,
4407                                values: Map::from_iter([
4408                                    ("id".into(), Value::from(id)),
4409                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
4410                                    (
4411                                        "title".into(),
4412                                        Value::from(format!("needle{}", (index + offset) % 16)),
4413                                    ),
4414                                ]),
4415                                base_version: None,
4416                            }
4417                        }
4418                    })
4419                    .collect::<Vec<_>>();
4420                for instance in [&mut incremental, &mut reference] {
4421                    instance.mutate(mutations.clone()).unwrap();
4422                }
4423                // Most frames touch only the table without unique constraints.
4424                // Mixed frames must retain full replay for both tables.
4425                let changes = (0..3)
4426                    .map(|offset| {
4427                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
4428                        let id = format!("row{}", (index + offset) % 16);
4429                        let values = Map::from_iter([
4430                            ("id".into(), Value::from(id.clone())),
4431                            (
4432                                "project_id".into(),
4433                                Value::from(format!("p{}", (index + 1) % 3)),
4434                            ),
4435                            (
4436                                "title".into(),
4437                                Value::from(format!("remote{index}_{offset}")),
4438                            ),
4439                        ]);
4440                        ssp2::model::Change {
4441                            table_index: table_index as u16,
4442                            row_id: id.clone(),
4443                            op: if (index + offset) % 5 == 0 {
4444                                Op::Delete
4445                            } else {
4446                                Op::Upsert
4447                            },
4448                            row_version: Some(index as i64 + 2),
4449                            scopes: vec![],
4450                            row: Some(
4451                                encode_row_json(
4452                                    incremental.schema.table(&tables[table_index]).unwrap(),
4453                                    &id,
4454                                    &values,
4455                                    &incremental.encryption,
4456                                )
4457                                .unwrap(),
4458                            ),
4459                        }
4460                    })
4461                    .collect::<Vec<_>>();
4462                reference.overlay_dirty.set(true);
4463                incremental
4464                    .apply_commit_frame(&tables, &changes, None)
4465                    .unwrap();
4466                reference
4467                    .apply_commit_frame(&tables, &changes, None)
4468                    .unwrap();
4469                for table in &tables {
4470                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
4471                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
4472                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
4473                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
4474                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
4475                    }
4476                }
4477                assert_eq!(incremental.local_revision(), reference.local_revision());
4478                assert_eq!(
4479                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4480                    serde_json::to_value(reference.drain_change_batches()).unwrap()
4481                );
4482            }
4483            assert_eq!(
4484                incremental.overlay_rebuild_count.get(),
4485                if unique { 81 } else { 21 }
4486            );
4487            assert_eq!(reference.overlay_rebuild_count.get(), 81);
4488        }
4489    }
4490
4491    #[test]
4492    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
4493        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
4494            "columns":[{"name":"id", "type":"string", "nullable":false},
4495                {"name":"title", "type":"string", "nullable":false}],
4496            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
4497        let mut client =
4498            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
4499        for (id, title) in [("a", "original"), ("b", "occupied")] {
4500            client
4501                .write_base_row(
4502                    "tasks",
4503                    &vec![
4504                        Some(ColumnValue::String(id.into())),
4505                        Some(ColumnValue::String(title.into())),
4506                    ],
4507                    1,
4508                )
4509                .unwrap();
4510        }
4511        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4512        let pending = client
4513            .mutate(vec![Mutation::Upsert {
4514                table: "tasks".into(),
4515                values: Map::from_iter([
4516                    ("id".into(), Value::from("a")),
4517                    ("title".into(), Value::from("available")),
4518                ]),
4519                base_version: None,
4520            }])
4521            .unwrap();
4522        assert_eq!(
4523            client
4524                .conn
4525                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4526                    .get::<_, String>(
4527                    0
4528                ))
4529                .unwrap(),
4530            "available"
4531        );
4532        for (title, expected) in [("available", "original"), ("occupied", "available")] {
4533            let change = ssp2::model::Change {
4534                table_index: 0,
4535                row_id: "b".into(),
4536                op: Op::Upsert,
4537                row_version: Some(2),
4538                scopes: vec![],
4539                row: Some(
4540                    encode_row_json(
4541                        client.schema.table("tasks").unwrap(),
4542                        "b",
4543                        &Map::from_iter([
4544                            ("id".into(), Value::from("b")),
4545                            ("title".into(), Value::from(title)),
4546                        ]),
4547                        &client.encryption,
4548                    )
4549                    .unwrap(),
4550                ),
4551            };
4552            client
4553                .apply_commit_frame(&["tasks".into()], &[change], None)
4554                .unwrap();
4555            assert_eq!(
4556                client
4557                    .conn
4558                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4559                        .get::<_, String>(
4560                        0
4561                    ))
4562                    .unwrap(),
4563                expected
4564            );
4565            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
4566        }
4567        assert_eq!(client.overlay_rebuild_count.get(), 3);
4568    }
4569
4570    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
4571        let schema = json!({ "version": 1, "tables": [
4572            { "name":"tasks", "primaryKey":"id", "columns":[
4573                {"name":"id","type":"string","nullable":false},
4574                {"name":"project_id","type":"string","nullable":false}],
4575                "scopes":[{"pattern":"project:{project_id}"}] },
4576            { "name":"catalogue", "primaryKey":"id", "columns":[
4577                {"name":"id","type":"string","nullable":false},
4578                {"name":"project_id","type":"string","nullable":false},
4579                {"name":"title","type":"string","nullable":false}],
4580                "scopes":[{"pattern":"project:{project_id}"}],
4581                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
4582        ] });
4583        let mut client =
4584            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
4585        if unchanged_rows > 0 {
4586            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();
4587        }
4588        client.rebuild_overlay().expect("rebuild overlay");
4589        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();
4590        let mut elapsed = Vec::new();
4591        for round in 1..=rounds {
4592            let pending = client
4593                .mutate(vec![Mutation::Upsert {
4594                    table: "tasks".into(),
4595                    values: Map::from_iter([
4596                        ("id".into(), json!("t1")),
4597                        ("project_id".into(), json!("p1")),
4598                    ]),
4599                    base_version: None,
4600                }])
4601                .unwrap();
4602            let started = std::time::Instant::now();
4603            client
4604                .write_base_row(
4605                    "tasks",
4606                    &vec![
4607                        Some(ColumnValue::String("t1".into())),
4608                        Some(ColumnValue::String("p1".into())),
4609                    ],
4610                    round,
4611                )
4612                .unwrap();
4613            let mut report = SyncReport::default();
4614            client
4615                .handle_push_results(
4616                    &[Frame::PushResult {
4617                        client_commit_id: pending.clone(),
4618                        status: PushStatus::Applied,
4619                        commit_seq: Some(round),
4620                        results: vec![OpResult::Applied { op_index: 0 }],
4621                    }],
4622                    &HashSet::from([pending.as_str()]),
4623                    &HashMap::new(),
4624                    &mut report,
4625                    Some(&pending),
4626                )
4627                .unwrap();
4628            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4629            elapsed.push(started.elapsed().as_nanos());
4630        }
4631        assert_eq!(
4632            client
4633                .conn
4634                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
4635                .unwrap(),
4636            0,
4637            "unchanged catalogue must never be copied"
4638        );
4639        assert_eq!(
4640            client
4641                .conn
4642                .query_row(
4643                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
4644                    [],
4645                    |r| r.get::<_, i64>(0)
4646                )
4647                .unwrap(),
4648            unchanged_rows
4649        );
4650        assert_eq!(
4651            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
4652            json!(rounds)
4653        );
4654        elapsed
4655    }
4656
4657    #[test]
4658    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
4659        let elapsed = measure_scoped_overlay(100_000, 1);
4660        eprintln!(
4661            "table-scoped overlay with 100k unchanged rows: {} ns",
4662            elapsed[0]
4663        );
4664    }
4665
4666    #[test]
4667    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
4668    fn benchmark_scoped_overlay() {
4669        for rows in [0, 100_000, 200_000] {
4670            let mut elapsed = measure_scoped_overlay(rows, 30);
4671            elapsed.sort_unstable();
4672            eprintln!(
4673                "{}",
4674                json!({"unchangedRows": rows, "rounds": elapsed.len(),
4675                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
4676                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
4677            );
4678        }
4679    }
4680
4681    #[test]
4682    fn released_context_discards_in_flight_ack_and_server_rows() {
4683        for barrier in [
4684            "preflight",
4685            "purge",
4686            "rebootstrap",
4687            "subscription",
4688            "failed-purge-replay",
4689            "failed-purge-publish",
4690            "failed-rebootstrap",
4691        ] {
4692            let mut client = client();
4693            client.set_meta(LOG_EPOCH_KEY, "epoch");
4694            client
4695                .subscribe(
4696                    "tasks".into(),
4697                    "tasks".into(),
4698                    vec![("project_id".into(), vec!["p1".into()])],
4699                    None,
4700                )
4701                .unwrap();
4702            let commit = client
4703                .mutate(vec![Mutation::Upsert {
4704                    table: "tasks".into(),
4705                    values: Map::from_iter([
4706                        ("id".into(), json!("local")),
4707                        ("project_id".into(), json!("p1")),
4708                    ]),
4709                    base_version: None,
4710                }])
4711                .unwrap();
4712            let prepared = client.prepare_sync_round(false).unwrap();
4713            client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
4714            let payload = encode_row_json(
4715                &client.schema.tables[0],
4716                "server",
4717                &Map::from_iter([
4718                    ("id".into(), json!("server")),
4719                    ("project_id".into(), json!("p1")),
4720                ]),
4721                &client.encryption,
4722            )
4723            .unwrap();
4724            let completed = crate::CompletedSyncRound {
4725                prepared,
4726                exchange: crate::round::ExchangeResult::Reply {
4727                    transport_failed: false,
4728                    downloads: Default::default(),
4729                    response: Ok(Message {
4730                        wire_version: WIRE_VERSION,
4731                        msg_kind: MsgKind::Response,
4732                        frames: vec![
4733                            Frame::RespHeader {
4734                                required_schema_version: None,
4735                                latest_schema_version: None,
4736                                log_epoch: Some("epoch".into()),
4737                                reset_required: Some(false),
4738                            },
4739                            Frame::PushResult {
4740                                client_commit_id: commit.clone(),
4741                                status: PushStatus::Applied,
4742                                commit_seq: Some(1),
4743                                results: vec![OpResult::Applied { op_index: 0 }],
4744                            },
4745                            Frame::SubStart {
4746                                id: "tasks".into(),
4747                                status: SubStatus::Active,
4748                                reason_code: String::new(),
4749                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4750                                bootstrap: true,
4751                            },
4752                            Frame::Commit {
4753                                commit_seq: 1,
4754                                created_at_ms: 0,
4755                                actor_id: "actor".into(),
4756                                tables: vec!["tasks".into()],
4757                                changes: vec![ssp2::model::Change {
4758                                    table_index: 0,
4759                                    row_id: "server".into(),
4760                                    op: Op::Upsert,
4761                                    row_version: Some(1),
4762                                    scopes: vec![("project_id".into(), "p1".into())],
4763                                    row: Some(payload),
4764                                }],
4765                            },
4766                            Frame::SubEnd {
4767                                next_cursor: 1,
4768                                bootstrap_state: None,
4769                            },
4770                        ],
4771                    }),
4772                },
4773            };
4774            match barrier {
4775                "preflight" => client.begin_security_preflight(),
4776                "purge" => {
4777                    client
4778                        .purge_local_data(&LocalDataPurgeInput {
4779                            purge_id: "pending-round".into(),
4780                            targets: vec![LocalDataPurgeTarget {
4781                                table: "tasks".into(),
4782                                selectors: BTreeMap::from([(
4783                                    "project_id".into(),
4784                                    vec!["p1".into()],
4785                                )]),
4786                            }],
4787                        })
4788                        .unwrap();
4789                }
4790                "rebootstrap" => {
4791                    client
4792                        .rebootstrap_local_data(&LocalDataRebootstrapInput {
4793                            rebootstrap_id: "pending-round".into(),
4794                        })
4795                        .unwrap();
4796                }
4797                "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4798                    let trigger = if barrier == "failed-purge-replay" {
4799                        "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4800                    } else {
4801                        "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4802                    };
4803                    client.conn.execute_batch(trigger).unwrap();
4804                    let revision = client.local_revision();
4805                    if barrier == "failed-rebootstrap" {
4806                        assert!(client
4807                            .rebootstrap_local_data(&LocalDataRebootstrapInput {
4808                                rebootstrap_id: "failed-round".into(),
4809                            })
4810                            .is_err());
4811                    } else {
4812                        assert!(client
4813                            .purge_local_data(&LocalDataPurgeInput {
4814                                purge_id: "failed-round".into(),
4815                                targets: vec![LocalDataPurgeTarget {
4816                                    table: "tasks".into(),
4817                                    selectors: BTreeMap::from([(
4818                                        "project_id".into(),
4819                                        vec!["p1".into()]
4820                                    )]),
4821                                }],
4822                            })
4823                            .is_err());
4824                    }
4825                    assert_eq!(client.local_revision(), revision);
4826                    assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4827                    client
4828                        .conn
4829                        .execute_batch("DROP TRIGGER fail_local")
4830                        .unwrap();
4831                    let applied = client.apply_sync_round(completed);
4832                    assert!(
4833                        matches!(
4834                            applied,
4835                            crate::AppliedSyncRound::Complete {
4836                                outcome: SyncOutcome::Ok(_),
4837                                ..
4838                            }
4839                        ),
4840                        "{barrier}"
4841                    );
4842                    assert!(client.pending_commit_ids().is_empty());
4843                    assert_eq!(
4844                        client
4845                            .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4846                            .unwrap()
4847                            .len(),
4848                        1
4849                    );
4850                    continue;
4851                }
4852                "subscription" => {
4853                    client.unsubscribe("tasks");
4854                    client
4855                        .subscribe(
4856                            "tasks".into(),
4857                            "tasks".into(),
4858                            vec![("project_id".into(), vec!["p2".into()])],
4859                            None,
4860                        )
4861                        .unwrap();
4862                }
4863                _ => unreachable!(),
4864            }
4865            let pending_before_reply = client.pending_commit_ids();
4866            let revision = client.local_revision();
4867            let applied = client.apply_sync_round(completed);
4868            assert!(
4869                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4870            );
4871            assert_eq!(client.local_revision(), revision);
4872            assert!(client
4873                .conn
4874                .query_row(
4875                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4876                    [],
4877                    |r| r.get::<_, bool>(0)
4878                )
4879                .unwrap());
4880            assert_eq!(client.pending_commit_ids(), pending_before_reply);
4881        }
4882    }
4883
4884    #[test]
4885    fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4886        struct DeadlineRecording {
4887            budget: std::time::Duration,
4888            seen: Vec<Option<std::time::Instant>>,
4889        }
4890        impl Transport for DeadlineRecording {
4891            fn round_deadline(&self) -> Option<std::time::Duration> {
4892                Some(self.budget)
4893            }
4894            fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4895                self.seen.push(deadline);
4896            }
4897            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4898                Err(TransportError::new("transport.failed", "injected"))
4899            }
4900            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4901                self.sync(request)
4902            }
4903            fn download_segment(
4904                &mut self,
4905                _: &SegmentRequest,
4906                _: &mut dyn FnMut(u64),
4907            ) -> Result<Vec<u8>, TransportError> {
4908                Err(TransportError::new("transport.failed", "injected"))
4909            }
4910            fn blob_upload_grant(
4911                &mut self,
4912                _: &str,
4913                _: u64,
4914                _: Option<&str>,
4915            ) -> Result<crate::BlobUploadGrant, TransportError> {
4916                Ok(crate::BlobUploadGrant::Present)
4917            }
4918            fn realtime_connect(&mut self) -> Result<(), TransportError> {
4919                Ok(())
4920            }
4921            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4922                Ok(())
4923            }
4924            fn realtime_close(&mut self) -> Result<(), TransportError> {
4925                Ok(())
4926            }
4927        }
4928
4929        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4930            {"name":"id","type":"string","nullable":false},
4931            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4932        let mut client =
4933            SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4934        client.set_meta(LOG_EPOCH_KEY, "epoch");
4935        client
4936            .upload_blob(b"pending upload", Some("text/plain".into()), None)
4937            .unwrap();
4938
4939        let mut transport = DeadlineRecording {
4940            budget: std::time::Duration::from_secs(30),
4941            seen: Vec::new(),
4942        };
4943        let prepared = client.prepare_sync_round(false).unwrap();
4944        assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4945        // Upload exchange -> Continue (the pin removal is persisted by apply).
4946        let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4947            crate::AppliedSyncRound::Continue(prepared) => prepared,
4948            crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4949        };
4950        // Main request exchange -> Complete.
4951        let completed = prepared.exchange(&mut transport);
4952        let anchor = completed
4953            .prepared
4954            .round_deadline_at
4955            .expect("the upload round anchors a deadline");
4956        assert!(matches!(
4957            client.apply_sync_round(completed),
4958            crate::AppliedSyncRound::Complete { .. }
4959        ));
4960        assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4961        assert_eq!(
4962            transport.seen[0],
4963            Some(anchor),
4964            "the upload anchors the round"
4965        );
4966        assert_eq!(
4967            transport.seen[1], None,
4968            "the upload exchange clears its scope"
4969        );
4970        assert_eq!(
4971            transport.seen[2],
4972            Some(anchor),
4973            "a continuation reuses the carried anchor instead of refreshing it"
4974        );
4975        assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4976
4977        // A fresh round anchors a new deadline from the transport's (now larger)
4978        // budget and never inherits the old one; the clock granule is irrelevant.
4979        transport.budget = std::time::Duration::from_secs(60);
4980        let prepared = client.prepare_sync_round(false).unwrap();
4981        let completed = prepared.exchange(&mut transport);
4982        let fresh = completed
4983            .prepared
4984            .round_deadline_at
4985            .expect("the next round anchors a deadline");
4986        client.apply_sync_round(completed);
4987        assert!(
4988            fresh >= anchor + std::time::Duration::from_secs(30),
4989            "the next round anchors a fresh deadline from the new budget"
4990        );
4991        assert_eq!(transport.seen[4], Some(fresh));
4992        assert_eq!(
4993            transport.seen[5], None,
4994            "the new round clears its scope too"
4995        );
4996    }
4997
4998    /// Records the round-deadline traffic so a test can prove whether a round
4999    /// asked for a budget and whether it ever scoped an anchor.
5000    #[derive(Default)]
5001    struct RoundDeadlineProbe {
5002        budget: Option<std::time::Duration>,
5003        budget_queries: std::cell::Cell<usize>,
5004        scoped: Vec<Option<std::time::Instant>>,
5005    }
5006
5007    impl Transport for RoundDeadlineProbe {
5008        fn round_deadline(&self) -> Option<std::time::Duration> {
5009            self.budget_queries.set(self.budget_queries.get() + 1);
5010            self.budget
5011        }
5012        fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
5013            self.scoped.push(deadline);
5014        }
5015        fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
5016            Err(TransportError::new("transport.failed", "injected"))
5017        }
5018        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
5019            self.sync(request)
5020        }
5021        fn download_segment(
5022            &mut self,
5023            _: &SegmentRequest,
5024            _: &mut dyn FnMut(u64),
5025        ) -> Result<Vec<u8>, TransportError> {
5026            Err(TransportError::new("transport.failed", "injected"))
5027        }
5028        fn blob_upload_grant(
5029            &mut self,
5030            _: &str,
5031            _: u64,
5032            _: Option<&str>,
5033        ) -> Result<crate::BlobUploadGrant, TransportError> {
5034            Ok(crate::BlobUploadGrant::Present)
5035        }
5036        fn realtime_connect(&mut self) -> Result<(), TransportError> {
5037            Ok(())
5038        }
5039        fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5040            Ok(())
5041        }
5042        fn realtime_close(&mut self) -> Result<(), TransportError> {
5043            Ok(())
5044        }
5045    }
5046
5047    #[test]
5048    fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
5049        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5050            {"name":"id","type":"string","nullable":false},
5051            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5052        let mut client =
5053            SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
5054        client.set_meta(LOG_EPOCH_KEY, "epoch");
5055        client
5056            .upload_blob(b"pending upload", Some("text/plain".into()), None)
5057            .unwrap();
5058        client
5059            .conn
5060            .execute_batch("DELETE FROM _syncular_blobs")
5061            .unwrap();
5062        let prepared = client.prepare_sync_round(false).unwrap();
5063        assert!(
5064            prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
5065            "the staged body must fail locally"
5066        );
5067        let mut transport = RoundDeadlineProbe {
5068            budget: Some(std::time::Duration::from_secs(30)),
5069            ..Default::default()
5070        };
5071        let completed = prepared.exchange(&mut transport);
5072        assert!(matches!(
5073            completed.exchange,
5074            crate::round::ExchangeResult::Upload { result: Err(_), .. }
5075        ));
5076        assert_eq!(
5077            transport.budget_queries.get(),
5078            0,
5079            "a round with no network work never asks for a budget"
5080        );
5081        assert_eq!(transport.scoped, vec![None]);
5082    }
5083
5084    #[test]
5085    fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
5086        struct HugeBudget;
5087        impl Transport for HugeBudget {
5088            fn round_deadline(&self) -> Option<std::time::Duration> {
5089                Some(std::time::Duration::MAX)
5090            }
5091            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
5092                panic!("no network for an invalid budget")
5093            }
5094            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
5095                self.sync(request)
5096            }
5097            fn download_segment(
5098                &mut self,
5099                _: &SegmentRequest,
5100                _: &mut dyn FnMut(u64),
5101            ) -> Result<Vec<u8>, TransportError> {
5102                panic!("no network for an invalid budget")
5103            }
5104            fn realtime_connect(&mut self) -> Result<(), TransportError> {
5105                Ok(())
5106            }
5107            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5108                Ok(())
5109            }
5110            fn realtime_close(&mut self) -> Result<(), TransportError> {
5111                Ok(())
5112            }
5113        }
5114
5115        let mut client = client();
5116        client.set_meta(LOG_EPOCH_KEY, "epoch");
5117        let prepared = client.prepare_sync_round(false).unwrap();
5118        let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
5119            crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
5120            crate::AppliedSyncRound::Continue(_) => {
5121                panic!("an unrepresentable budget must not continue")
5122            }
5123        };
5124        match outcome {
5125            SyncOutcome::Failed {
5126                error_code,
5127                message,
5128                ..
5129            } => {
5130                assert_eq!(error_code, "sync.invalid_request");
5131                assert_eq!(message, "transport round deadline is out of range");
5132            }
5133            _ => panic!("an unrepresentable budget must fail synchronously"),
5134        }
5135    }
5136
5137    #[test]
5138    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
5139        for failure in ["visible", "revision", "commit"] {
5140            let mut client = client();
5141            let id = client
5142                .mutate(vec![Mutation::Upsert {
5143                    table: "tasks".into(),
5144                    values: Map::from_iter([
5145                        ("id".into(), Value::from("t1")),
5146                        ("project_id".into(), Value::from("pending")),
5147                    ]),
5148                    base_version: None,
5149                }])
5150                .unwrap();
5151            client.drain_change_batches();
5152            let revision = client.local_revision();
5153            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5154            let trigger = match failure {
5155                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
5156                "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"),
5157                _ => "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(),
5158            };
5159            client.conn.execute_batch(&trigger).unwrap();
5160            let change = ssp2::model::Change {
5161                table_index: 0,
5162                row_id: "t1".into(),
5163                op: Op::Upsert,
5164                row_version: Some(2),
5165                scopes: vec![],
5166                row: Some(
5167                    encode_row_json(
5168                        client.schema.table("tasks").unwrap(),
5169                        "t1",
5170                        &Map::from_iter([
5171                            ("id".into(), Value::from("t1")),
5172                            ("project_id".into(), Value::from("remote")),
5173                        ]),
5174                        &client.encryption,
5175                    )
5176                    .unwrap(),
5177                ),
5178            };
5179            assert!(client
5180                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
5181                .is_err());
5182            assert!(client.conn.is_autocommit());
5183            assert!(!client.overlay_dirty.get());
5184            assert_eq!(client.local_revision(), revision);
5185            assert!(client.drain_change_batches().is_empty());
5186            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5187            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5188            assert!(client
5189                .query("SELECT * FROM _syncular_base_tasks", &[])
5190                .unwrap()
5191                .is_empty());
5192            client
5193                .conn
5194                .execute_batch("DROP TRIGGER fail_pending")
5195                .unwrap();
5196            client
5197                .apply_commit_frame(&["tasks".into()], &[change], None)
5198                .unwrap();
5199            assert_eq!(client.local_revision(), revision + 1);
5200            assert_eq!(client.pending_commit_ids(), vec![id]);
5201            // The overlay keeps the pending write visible; the row now carries
5202            // the server row's version it is layered on (§7.1).
5203            let mut expected = before[0].clone();
5204            expected.insert("_syncular_version".to_owned(), Value::from(2));
5205            assert_eq!(
5206                client.query("SELECT * FROM tasks", &[]).unwrap(),
5207                vec![expected]
5208            );
5209            assert_eq!(
5210                client
5211                    .conn
5212                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
5213                        .get::<_, String>(0))
5214                    .unwrap(),
5215                "remote"
5216            );
5217        }
5218    }
5219
5220    #[test]
5221    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
5222        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
5223            "columns": [
5224                {"name":"id", "type":"string", "nullable":false},
5225                {"name":"project_id", "type":"string", "nullable":false},
5226                {"name":"title", "type":"string", "nullable":false}],
5227            "scopes": [{"pattern":"project:{project_id}"}],
5228            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
5229            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
5230        let mut incremental =
5231            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5232        let mut reference =
5233            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5234        for instance in [&mut incremental, &mut reference] {
5235            for index in 0..16 {
5236                instance
5237                    .write_base_row(
5238                        "tasks",
5239                        &vec![
5240                            Some(ColumnValue::String(format!("task{index}"))),
5241                            Some(ColumnValue::String("p0".to_owned())),
5242                            Some(ColumnValue::String(format!("base{index}"))),
5243                        ],
5244                        1,
5245                    )
5246                    .unwrap();
5247            }
5248            instance
5249                .rebuild_overlay_if_dirty()
5250                .expect("rebuild overlay");
5251            instance.overlay_rebuild_count.set(0);
5252        }
5253        for index in 0..100 {
5254            let mutations = (0..1 + index % 4)
5255                .map(|offset| {
5256                    let id = format!("task{}", (index + offset) % 24);
5257                    if (index + offset) % 7 == 0 {
5258                        Mutation::Delete {
5259                            table: "tasks".to_owned(),
5260                            row_id: id,
5261                            base_version: None,
5262                        }
5263                    } else {
5264                        Mutation::Upsert {
5265                            table: "tasks".to_owned(),
5266                            values: Map::from_iter([
5267                                ("id".to_owned(), Value::from(id)),
5268                                (
5269                                    "project_id".to_owned(),
5270                                    Value::from(format!("p{}", index % 2)),
5271                                ),
5272                                (
5273                                    "title".to_owned(),
5274                                    Value::from(format!("needle{}", (index + offset) % 24)),
5275                                ),
5276                            ]),
5277                            base_version: None,
5278                        }
5279                    }
5280                })
5281                .collect::<Vec<_>>();
5282            reference.overlay_dirty.set(true);
5283            incremental.mutate(mutations.clone()).unwrap();
5284            reference.mutate(mutations).unwrap();
5285            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
5286                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
5287                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
5288            }
5289            assert_eq!(incremental.local_revision(), reference.local_revision());
5290            assert_eq!(
5291                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
5292                serde_json::to_value(reference.drain_change_batches()).unwrap()
5293            );
5294        }
5295        assert_eq!(incremental.outbox.len(), 100);
5296        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
5297        assert_eq!(reference.overlay_rebuild_count.get(), 100);
5298    }
5299
5300    #[test]
5301    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
5302        for fail_at in ["outbox", "revision"] {
5303            let mut client = client();
5304            let values = Map::from_iter([
5305                ("id".to_owned(), Value::from("t1")),
5306                ("project_id".to_owned(), Value::from("p1")),
5307            ]);
5308            let mutation = Mutation::Upsert {
5309                table: "tasks".to_owned(),
5310                values,
5311                base_version: None,
5312            };
5313            let trigger = if fail_at == "outbox" {
5314                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
5315            } else {
5316                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
5317            };
5318            client.conn.execute_batch(&trigger).unwrap();
5319            let revision = client.local_revision();
5320            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
5321            assert_eq!(client.local_revision(), revision);
5322            assert!(client.outbox.is_empty());
5323            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5324            assert_eq!(
5325                client
5326                    .conn
5327                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5328                        .get::<_, i64>(0))
5329                    .unwrap(),
5330                0
5331            );
5332            assert!(client.drain_change_batches().is_empty());
5333            assert!(!client.overlay_dirty.get());
5334            client
5335                .conn
5336                .execute_batch("DROP TRIGGER fail_append")
5337                .unwrap();
5338            client.mutate(vec![mutation]).unwrap();
5339            assert_eq!(client.outbox.len(), 1);
5340            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
5341        }
5342    }
5343
5344    #[test]
5345    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
5346        let mut client = client();
5347        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
5348        let values = Map::from_iter([
5349            ("id".to_owned(), Value::from("t1")),
5350            ("project_id".to_owned(), Value::from("p1")),
5351        ]);
5352        let payload = encode_row_json(
5353            client.schema.table("tasks").unwrap(),
5354            "t1",
5355            &values,
5356            &client.encryption,
5357        )
5358        .unwrap();
5359        let first = ssp2::model::Change {
5360            table_index: 0,
5361            row_id: "t1".to_owned(),
5362            op: Op::Upsert,
5363            row_version: Some(1),
5364            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
5365            row: Some(payload),
5366        };
5367        let mut invalid = first.clone();
5368        invalid.table_index = 1;
5369        let revision = client.local_revision();
5370        client.begin_observation("failed_mirror").unwrap();
5371        assert!(client
5372            .apply_commit_changes(&tables, &[first, invalid])
5373            .is_err());
5374        client.rollback_observation("failed_mirror");
5375        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
5376        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5377        assert_eq!(
5378            client
5379                .conn
5380                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
5381                    .get::<_, i64>(
5382                    0
5383                ))
5384                .unwrap(),
5385            0
5386        );
5387        assert_eq!(client.local_revision(), revision);
5388        assert!(client.drain_change_batches().is_empty());
5389    }
5390
5391    #[test]
5392    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
5393        for failure in ["journal", "revision", "commit", "later-frame"] {
5394            let mut client = client();
5395            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5396            for id in ["first", "second"] {
5397                client
5398                    .mutate(vec![Mutation::Upsert {
5399                        table: "tasks".to_owned(),
5400                        values: Map::from_iter([
5401                            ("id".to_owned(), Value::from(id)),
5402                            ("project_id".to_owned(), Value::from("p1")),
5403                        ]),
5404                        base_version: None,
5405                    }])
5406                    .unwrap();
5407            }
5408            let (_, meta) = client.build_request(false).unwrap();
5409            // A malicious or stale response must not drain an unsent local commit.
5410            client
5411                .mutate(vec![Mutation::Upsert {
5412                    table: "tasks".to_owned(),
5413                    values: Map::from_iter([
5414                        ("id".to_owned(), Value::from("later")),
5415                        ("project_id".to_owned(), Value::from("p1")),
5416                    ]),
5417                    base_version: None,
5418                }])
5419                .unwrap();
5420            let ids = client.pending_commit_ids();
5421            let rows = client
5422                .query("SELECT * FROM tasks ORDER BY id", &[])
5423                .unwrap();
5424            let revision = client.local_revision();
5425            client.drain_change_batches();
5426            if failure != "later-frame" {
5427                client.conn.execute_batch(match failure {
5428                    "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",
5429                    "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",
5430                    _ => "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",
5431                }).unwrap();
5432            }
5433            let mut response = Message {
5434                wire_version: WIRE_VERSION,
5435                msg_kind: MsgKind::Response,
5436                frames: vec![Frame::RespHeader {
5437                    required_schema_version: None,
5438                    latest_schema_version: None,
5439                    log_epoch: Some("epoch-1".to_owned()),
5440                    reset_required: Some(false),
5441                }],
5442            };
5443            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
5444                response.frames.push(Frame::PushResult {
5445                    client_commit_id: id.to_owned(),
5446                    status: PushStatus::Applied,
5447                    commit_seq: Some(1),
5448                    results: vec![OpResult::Applied { op_index: 0 }],
5449                });
5450            }
5451            if failure == "later-frame" {
5452                response.frames.push(Frame::Error {
5453                    code: "sync.invalid_request".to_owned(),
5454                    message: "later error".to_owned(),
5455                    category: "protocol".to_owned(),
5456                    retryable: false,
5457                    recommended_action: "retry".to_owned(),
5458                    details: None,
5459                });
5460            }
5461            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5462            let outcome = client.process_response(&mut transport, response.clone(), &meta);
5463            // §1.4 rule 4: the deferred foreign key of "commit" fails the
5464            // response's coalesced COMMIT instead of the run's RELEASE.
5465            let expected = match failure {
5466                "later-frame" => "sync.invalid_request",
5467                "commit" => "storage.failed",
5468                _ => "client.outcome_persistence_failed",
5469            };
5470            assert!(
5471                matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5472                "{failure}: {outcome:?}"
5473            );
5474            if failure == "later-frame" {
5475                assert_eq!(client.pending_commit_ids(), ids[2..]);
5476                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5477                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
5478                let batches = client.drain_change_batches();
5479                assert_eq!(batches.len(), 1);
5480                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
5481                assert_eq!(client.local_revision(), revision + 1);
5482                response.frames.pop();
5483            } else {
5484                assert_eq!(client.pending_commit_ids(), ids);
5485                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5486                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
5487                assert_eq!(client.local_revision(), revision);
5488                assert!(client.drain_change_batches().is_empty());
5489                assert_eq!(
5490                    client
5491                        .query("SELECT * FROM tasks ORDER BY id", &[])
5492                        .unwrap(),
5493                    rows
5494                );
5495                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
5496            }
5497            for frame in &mut response.frames {
5498                if let Frame::PushResult { status, .. } = frame {
5499                    *status = PushStatus::Cached;
5500                }
5501            }
5502            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
5503            else {
5504                panic!("retry failed");
5505            };
5506            assert_eq!(
5507                report.applied,
5508                if failure == "later-frame" {
5509                    vec![]
5510                } else {
5511                    ids[..2].to_vec()
5512                }
5513            );
5514            assert_eq!(client.pending_commit_ids(), ids[2..]);
5515            let batches = client.drain_change_batches();
5516            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
5517            if let Some(batch) = batches.first() {
5518                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
5519            }
5520            assert_eq!(client.local_revision(), revision + 1);
5521        }
5522    }
5523
5524    #[test]
5525    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
5526        for failure in ["revision", "journal", "commit"] {
5527            for status in ["applied", "cached", "rejected", "conflict"] {
5528                let mut client = client();
5529                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5530                for id in ["first", "later"] {
5531                    client
5532                        .mutate(vec![Mutation::Upsert {
5533                            table: "tasks".to_owned(),
5534                            values: Map::from_iter([
5535                                ("id".to_owned(), Value::from(id)),
5536                                ("project_id".to_owned(), Value::from("p1")),
5537                            ]),
5538                            base_version: None,
5539                        }])
5540                        .unwrap();
5541                }
5542                let (_, meta) = client.build_request(false).unwrap();
5543                let ids = client.pending_commit_ids();
5544                let rows = client
5545                    .query("SELECT * FROM tasks ORDER BY id", &[])
5546                    .unwrap();
5547                let revision = client.local_revision();
5548                client.drain_change_batches();
5549                let (push_status, results) = match status {
5550                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
5551                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
5552                    "conflict" => (
5553                        PushStatus::Rejected,
5554                        vec![OpResult::Conflict {
5555                            op_index: 0,
5556                            code: "sync.version_conflict".to_owned(),
5557                            message: "conflict".to_owned(),
5558                            server_version: 1,
5559                            server_row: encode_row_json(
5560                                client.schema.table("tasks").unwrap(),
5561                                "first",
5562                                &Map::from_iter([
5563                                    ("id".to_owned(), Value::from("first")),
5564                                    ("project_id".to_owned(), Value::from("p1")),
5565                                ]),
5566                                &client.encryption,
5567                            )
5568                            .unwrap(),
5569                            conflict_columns: Vec::new(),
5570                        }],
5571                    ),
5572                    _ => (
5573                        PushStatus::Rejected,
5574                        vec![OpResult::Error {
5575                            op_index: 0,
5576                            code: "sync.validation_failed".to_owned(),
5577                            message: "rejected".to_owned(),
5578                            retryable: false,
5579                        }],
5580                    ),
5581                };
5582                let response = Message {
5583                    wire_version: WIRE_VERSION,
5584                    msg_kind: MsgKind::Response,
5585                    frames: vec![
5586                        Frame::RespHeader {
5587                            required_schema_version: None,
5588                            latest_schema_version: None,
5589                            log_epoch: Some("epoch-1".to_owned()),
5590                            reset_required: Some(false),
5591                        },
5592                        Frame::PushResult {
5593                            client_commit_id: ids[0].clone(),
5594                            status: push_status,
5595                            commit_seq: Some(1),
5596                            results,
5597                        },
5598                    ],
5599                };
5600                client.conn.execute_batch(match failure {
5601                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
5602                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
5603                    _ => "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",
5604                }).unwrap();
5605                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5606                let outcome = client.process_response(&mut transport, response.clone(), &meta);
5607                assert_eq!(
5608                    client.pending_commit_ids(),
5609                    ids,
5610                    "{failure}/{status}: pending identity"
5611                );
5612                assert_eq!(
5613                    client.local_revision(),
5614                    revision,
5615                    "{failure}/{status}: revision"
5616                );
5617                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5618                assert!(client.conflicts().is_empty());
5619                assert!(client.rejections().is_empty());
5620                assert!(client.drain_change_batches().is_empty());
5621                assert_eq!(
5622                    client
5623                        .query("SELECT * FROM tasks ORDER BY id", &[])
5624                        .unwrap(),
5625                    rows
5626                );
5627                // §1.4 rule 4: the deferred foreign key of "commit" fails the
5628                // response's coalesced COMMIT instead of the result's RELEASE.
5629                let expected = if failure == "commit" {
5630                    "storage.failed"
5631                } else {
5632                    "client.outcome_persistence_failed"
5633                };
5634                assert!(
5635                    matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5636                    "{failure}/{status}: failed round"
5637                );
5638                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
5639                assert!(matches!(
5640                    client.process_response(&mut transport, response, &meta),
5641                    SyncOutcome::Ok(_)
5642                ));
5643                assert_eq!(client.pending_commit_ids(), ids[1..]);
5644                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5645                assert_eq!(client.drain_change_batches().len(), 1);
5646            }
5647        }
5648    }
5649
5650    #[test]
5651    fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
5652        let mut client = client();
5653        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5654        for index in 0..4 {
5655            client
5656                .mutate(vec![Mutation::Upsert {
5657                    table: "tasks".to_owned(),
5658                    values: Map::from_iter([
5659                        ("id".to_owned(), Value::from(format!("task-{index}"))),
5660                        ("project_id".to_owned(), Value::from("project-1")),
5661                    ]),
5662                    base_version: None,
5663                }])
5664                .expect("queue commit");
5665        }
5666
5667        let (_, request_meta) = client.build_request(false).unwrap();
5668        let ids = &request_meta.pushed_ids;
5669        assert_eq!(ids.len(), 4);
5670        let response = Message {
5671            wire_version: WIRE_VERSION,
5672            msg_kind: MsgKind::Response,
5673            frames: vec![
5674                Frame::RespHeader {
5675                    required_schema_version: None,
5676                    latest_schema_version: None,
5677                    log_epoch: Some("epoch-1".to_owned()),
5678                    reset_required: Some(false),
5679                },
5680                Frame::PushResult {
5681                    client_commit_id: ids[0].clone(),
5682                    status: PushStatus::Applied,
5683                    commit_seq: Some(1),
5684                    results: vec![OpResult::Applied { op_index: 0 }],
5685                },
5686                Frame::PushResult {
5687                    client_commit_id: ids[1].clone(),
5688                    status: PushStatus::Cached,
5689                    commit_seq: Some(2),
5690                    results: vec![OpResult::Applied { op_index: 0 }],
5691                },
5692                Frame::PushResult {
5693                    client_commit_id: ids[2].clone(),
5694                    status: PushStatus::Rejected,
5695                    commit_seq: None,
5696                    results: vec![OpResult::Error {
5697                        op_index: 0,
5698                        code: "sync.validation_failed".to_owned(),
5699                        message: "rejected".to_owned(),
5700                        retryable: false,
5701                    }],
5702                },
5703                Frame::PushResult {
5704                    client_commit_id: ids[3].clone(),
5705                    status: PushStatus::Rejected,
5706                    commit_seq: None,
5707                    results: vec![OpResult::Error {
5708                        op_index: 0,
5709                        code: "sync.idempotency_cache_miss".to_owned(),
5710                        message: "retry".to_owned(),
5711                        retryable: true,
5712                    }],
5713                },
5714            ],
5715        };
5716        let mut transport =
5717            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
5718
5719        client.overlay_rebuild_count.set(0);
5720        client.outcome_prune_count.set(0);
5721        let outcome = client.process_response(&mut transport, response, &request_meta);
5722        let SyncOutcome::Ok(report) = outcome else {
5723            panic!("mixed push-result response failed");
5724        };
5725
5726        assert_eq!(report.applied, ids[..2]);
5727        assert_eq!(report.rejected, ids[2..3]);
5728        assert_eq!(report.retryable, ids[3..4]);
5729        assert_eq!(client.pending_commit_ids(), ids[3..4]);
5730        assert_eq!(
5731            client
5732                .query("SELECT id FROM tasks ORDER BY id", &[])
5733                .expect("query visible overlay"),
5734            ["task-0", "task-1", "task-3"]
5735                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
5736                .to_vec()
5737        );
5738        // The successful ACK run and final rejection have distinct durable boundaries.
5739        assert_eq!(client.overlay_rebuild_count.get(), 2);
5740        assert_eq!(client.outcome_prune_count.get(), 1);
5741    }
5742
5743    fn replay_schema(fts: bool) -> Value {
5744        json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
5745            {"name":"id","type":"string","nullable":false},
5746            {"name":"project_id","type":"string","nullable":false},
5747            {"name":"title","type":"string","nullable":false},
5748            {"name":"done","type":"boolean","nullable":false}
5749        ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5750    }
5751
5752    #[test]
5753    fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5754        for schema_reset in [false, true] {
5755            let mut schema = replay_schema(false);
5756            let mut client =
5757                SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5758            let id = client
5759                .mutate(vec![Mutation::Upsert {
5760                    table: "tasks".into(),
5761                    values: Map::from_iter([
5762                        ("id".into(), json!("queued")),
5763                        ("project_id".into(), json!("p1")),
5764                        ("title".into(), json!("old")),
5765                        ("done".into(), json!(false)),
5766                    ]),
5767                    base_version: None,
5768                }])
5769                .unwrap();
5770            client.set_meta(LOG_EPOCH_KEY, "old");
5771            let revision = client.local_revision();
5772            let needed = client.sync_needed;
5773            client.drain_change_batches();
5774            client.drain_sync_intents();
5775            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();
5776            if schema_reset {
5777                schema["version"] = json!(2);
5778                schema["tables"][0]["columns"]
5779                    .as_array_mut()
5780                    .unwrap()
5781                    .retain(|column| column["name"] != "title");
5782                client.schema = parse_schema_json(&schema).unwrap();
5783                assert!(client.run_schema_reset().is_err());
5784            } else {
5785                assert!(client.run_log_epoch_reset("new").is_err());
5786            }
5787            assert!(!client.upgrading);
5788            assert_eq!(client.sync_needed, needed);
5789            assert_eq!(client.local_revision(), revision);
5790            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5791            assert!(client.rejections.is_empty());
5792            assert!(client.commit_outcome(&id).unwrap().is_none());
5793            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5794            assert_eq!(
5795                client
5796                    .query("SELECT title FROM tasks WHERE id='queued'", &[])
5797                    .unwrap()[0]["title"],
5798                "old"
5799            );
5800            assert_eq!(
5801                client
5802                    .conn
5803                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5804                        .get::<_, i64>(0))
5805                    .unwrap(),
5806                1
5807            );
5808            assert!(client.drain_change_batches().is_empty());
5809            assert!(client.drain_sync_intents().is_empty());
5810            assert!(client.conn.is_autocommit());
5811        }
5812    }
5813
5814    #[test]
5815    fn cached_blob_cleanup_propagates_delete_failures() {
5816        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5817            {"name":"id","type":"string","nullable":false},
5818            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5819        let mut client =
5820            SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5821        client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5822        client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5823        client
5824            .subscribe("attachments".into(), "attachments".into(), vec![], None)
5825            .unwrap();
5826        client.subs[0].effective = Some(vec![]);
5827        client.persist_sub(&client.subs[0]).unwrap();
5828        let revision = client.local_revision();
5829        let (_, meta) = client.build_request(false).unwrap();
5830        let mut transport = CountingRealtimeTransport::default();
5831        let mut report = SyncReport::default();
5832        assert!(client
5833            .process_section(
5834                &mut transport,
5835                "attachments",
5836                SubStatus::Revoked,
5837                "sync.scope_revoked",
5838                vec![],
5839                vec![],
5840                Some((0, None)),
5841                &meta,
5842                &mut report,
5843                &HashSet::new()
5844            )
5845            .is_err());
5846        assert_eq!(client.subs[0].state, SubState::Active);
5847        assert_eq!(client.local_revision(), revision);
5848        assert!(report.revoked.is_empty());
5849        assert!(client.conn.is_autocommit());
5850        client
5851            .conn
5852            .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5853            .unwrap();
5854        assert!(client
5855            .process_section(
5856                &mut transport,
5857                "attachments",
5858                SubStatus::Revoked,
5859                "sync.scope_revoked",
5860                vec![],
5861                vec![],
5862                Some((0, None)),
5863                &meta,
5864                &mut report,
5865                &HashSet::new()
5866            )
5867            .is_ok());
5868        assert_eq!(client.subs[0].state, SubState::Revoked);
5869        assert_eq!(
5870            client
5871                .conn
5872                .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5873                    .get::<_, i64>(0))
5874                .unwrap(),
5875            0
5876        );
5877    }
5878
5879    #[test]
5880    fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5881        let path =
5882            std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5883        let schema = replay_schema(false);
5884        let mut client = SyncClient::open_path(
5885            "replay".into(),
5886            &schema,
5887            ClientLimits::default(),
5888            path.to_str().unwrap(),
5889        )
5890        .unwrap();
5891        let ids = ["early", "bad"].map(|id| {
5892            client
5893                .mutate(vec![Mutation::Upsert {
5894                    table: "tasks".into(),
5895                    values: Map::from_iter([
5896                        ("id".into(), json!(id)),
5897                        ("project_id".into(), json!("p1")),
5898                        ("title".into(), json!(id)),
5899                        ("done".into(), json!(false)),
5900                    ]),
5901                    base_version: None,
5902                }])
5903                .unwrap()
5904        });
5905        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();
5906        let revision = client.local_revision();
5907        drop(client);
5908        let error = SyncClient::open_path(
5909            "replay".into(),
5910            &schema,
5911            ClientLimits::default(),
5912            path.to_str().unwrap(),
5913        )
5914        .err()
5915        .expect("reopen must fail");
5916        assert!(error.contains("replay failed"));
5917        let conn = Connection::open(&path).unwrap();
5918        assert_eq!(
5919            conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5920                .unwrap(),
5921            0
5922        );
5923        assert_eq!(
5924            conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5925                .get::<_, i64>(0))
5926                .unwrap(),
5927            2
5928        );
5929        assert_eq!(
5930            meta_get(&conn, LOCAL_REVISION_KEY),
5931            Some(revision.to_string())
5932        );
5933        conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5934        drop(conn);
5935        let reopened = SyncClient::open_path(
5936            "replay".into(),
5937            &schema,
5938            ClientLimits::default(),
5939            path.to_str().unwrap(),
5940        )
5941        .unwrap();
5942        assert_eq!(reopened.pending_commit_ids(), ids);
5943        assert_eq!(
5944            reopened
5945                .query("SELECT id FROM tasks ORDER BY id", &[])
5946                .unwrap()
5947                .len(),
5948            2
5949        );
5950        drop(reopened);
5951        std::fs::remove_file(path).unwrap();
5952    }
5953
5954    #[test]
5955    fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5956        let schema = replay_schema(false);
5957        let mut client =
5958            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5959        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5960        client
5961            .write_base_row(
5962                "tasks",
5963                &vec![
5964                    Some(ColumnValue::String("queued".into())),
5965                    Some(ColumnValue::String("p1".into())),
5966                    Some(ColumnValue::String("server".into())),
5967                    Some(ColumnValue::Boolean(false)),
5968                ],
5969                1,
5970            )
5971            .unwrap();
5972        client.rebuild_overlay().unwrap();
5973        let id = client
5974            .patch(
5975                "tasks",
5976                "queued",
5977                Map::from_iter([("title".into(), json!("local"))]),
5978                Some(1),
5979            )
5980            .unwrap();
5981        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5982        let base = client
5983            .query("SELECT * FROM _syncular_base_tasks", &[])
5984            .unwrap();
5985        let revision = client.local_revision();
5986        client.drain_change_batches();
5987        client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5988        let remote = vec![
5989            Some(ColumnValue::String("queued".into())),
5990            Some(ColumnValue::String("p1".into())),
5991            Some(ColumnValue::String("remote".into())),
5992            Some(ColumnValue::Boolean(false)),
5993        ];
5994        let bytes = crate::values::encode_row_json(
5995            client.schema.table("tasks").unwrap(),
5996            "queued",
5997            &Map::from_iter(
5998                client
5999                    .schema
6000                    .table("tasks")
6001                    .unwrap()
6002                    .columns
6003                    .iter()
6004                    .zip(&remote)
6005                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
6006            ),
6007            &client.encryption,
6008        )
6009        .unwrap();
6010        let change = ssp2::model::Change {
6011            table_index: 0,
6012            op: Op::Upsert,
6013            row_id: "queued".into(),
6014            row_version: Some(2),
6015            scopes: vec![("project_id".into(), "p1".into())],
6016            row: Some(bytes),
6017        };
6018        assert!(client
6019            .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
6020            .is_err());
6021        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
6022        assert_eq!(
6023            client
6024                .query("SELECT * FROM _syncular_base_tasks", &[])
6025                .unwrap(),
6026            base
6027        );
6028        assert_eq!(client.local_revision(), revision);
6029        assert!(client.drain_change_batches().is_empty());
6030        let response = Message {
6031            wire_version: 2,
6032            msg_kind: MsgKind::Response,
6033            frames: vec![
6034                Frame::RespHeader {
6035                    required_schema_version: None,
6036                    latest_schema_version: None,
6037                    log_epoch: Some("epoch-1".into()),
6038                    reset_required: Some(false),
6039                },
6040                Frame::PushResult {
6041                    client_commit_id: id.clone(),
6042                    status: PushStatus::Applied,
6043                    commit_seq: Some(2),
6044                    results: vec![OpResult::Applied { op_index: 0 }],
6045                },
6046            ],
6047        };
6048        let (_, meta) = client.build_request(false).unwrap();
6049        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
6050        assert!(
6051            matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
6052        );
6053        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6054        assert!(client.commit_outcome(&id).unwrap().is_none());
6055        assert_eq!(
6056            client
6057                .conn
6058                .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
6059                    .get::<_, i64>(
6060                    0
6061                ))
6062                .unwrap(),
6063            0
6064        );
6065        assert_eq!(
6066            client
6067                .conn
6068                .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
6069                    row.get::<_, i64>(0)
6070                })
6071                .unwrap(),
6072            0
6073        );
6074        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
6075        assert_eq!(client.local_revision(), revision);
6076        assert!(client.drain_change_batches().is_empty());
6077        client
6078            .conn
6079            .execute_batch("DROP TRIGGER fail_replay")
6080            .unwrap();
6081        assert!(matches!(
6082            client.process_response(&mut transport, response, &meta),
6083            SyncOutcome::Ok(_)
6084        ));
6085        client
6086            .apply_commit_frame(&["tasks".into()], &[change], Some(2))
6087            .unwrap();
6088        assert!(client.pending_commit_ids().is_empty());
6089        assert_eq!(
6090            client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
6091            json!("remote")
6092        );
6093    }
6094
6095    #[test]
6096    fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
6097        let client = SyncClient::new(
6098            "replay".into(),
6099            &replay_schema(false),
6100            ClientLimits::default(),
6101        )
6102        .unwrap();
6103        let table = client.schema.table("tasks").unwrap();
6104        let patch = OutboxOp {
6105            table: "tasks".into(),
6106            row_id: "absent".into(),
6107            upsert: true,
6108            base_version: Some(1),
6109            values: Some(Map::from_iter([
6110                ("id".into(), json!("absent")),
6111                ("title".into(), json!("patch")),
6112            ])),
6113        };
6114        assert!(client.visible_row(table, "absent").unwrap().is_none());
6115        client
6116            .apply_outbox_ops([&patch])
6117            .expect("sparse replay over truly absent row");
6118        client
6119            .conn
6120            .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
6121            .unwrap();
6122        assert!(client
6123            .visible_row(table, "bad")
6124            .unwrap_err()
6125            .starts_with("sync.local_corrupt:"));
6126        let mut invalid = patch.clone();
6127        invalid.values = Some(Map::from_iter([
6128            ("id".into(), json!("bad-value")),
6129            ("project_id".into(), json!("p1")),
6130            ("title".into(), json!("title")),
6131            ("done".into(), json!({"bad":true})),
6132        ]));
6133        assert!(client
6134            .apply_outbox_ops([&invalid])
6135            .unwrap_err()
6136            .starts_with("sync.local_corrupt:"));
6137        client.conn.execute_batch("DROP TABLE tasks").unwrap();
6138        assert!(
6139            client.apply_outbox_ops([&patch]).is_err(),
6140            "SQL failure must not count as absence"
6141        );
6142    }
6143
6144    #[test]
6145    fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
6146        let mut schema = replay_schema(false);
6147        schema["tables"][0]["indexes"] =
6148            json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
6149        let mut client =
6150            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
6151        let id = client
6152            .mutate(vec![Mutation::Upsert {
6153                table: "tasks".into(),
6154                values: Map::from_iter([
6155                    ("id".into(), json!("queued")),
6156                    ("project_id".into(), json!("p1")),
6157                    ("title".into(), json!("occupied")),
6158                    ("done".into(), json!(false)),
6159                ]),
6160                base_version: None,
6161            }])
6162            .unwrap();
6163        client
6164            .write_base_row(
6165                "tasks",
6166                &vec![
6167                    Some(ColumnValue::String("server".into())),
6168                    Some(ColumnValue::String("p1".into())),
6169                    Some(ColumnValue::String("occupied".into())),
6170                    Some(ColumnValue::Boolean(false)),
6171                ],
6172                1,
6173            )
6174            .unwrap();
6175        client.rebuild_overlay_if_dirty().unwrap();
6176        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6177        assert!(client
6178            .visible_row(client.schema.table("tasks").unwrap(), "queued")
6179            .unwrap()
6180            .is_none());
6181        assert!(client.rejections().is_empty());
6182        client.delete_base_row("tasks", "server").unwrap();
6183        client.rebuild_overlay_if_dirty().unwrap();
6184        assert!(client
6185            .visible_row(client.schema.table("tasks").unwrap(), "queued")
6186            .unwrap()
6187            .is_some());
6188        client
6189            .write_base_row(
6190                "tasks",
6191                &vec![
6192                    Some(ColumnValue::String("base".into())),
6193                    Some(ColumnValue::String("p1".into())),
6194                    Some(ColumnValue::String("base-title".into())),
6195                    Some(ColumnValue::Boolean(false)),
6196                ],
6197                1,
6198            )
6199            .unwrap();
6200        client.conn.execute_batch("DROP TABLE tasks").unwrap();
6201        client
6202            .retain_failed_commit(
6203                "retained",
6204                &[OutboxOp {
6205                    table: "tasks".into(),
6206                    row_id: "base".into(),
6207                    upsert: true,
6208                    base_version: Some(1),
6209                    values: Some(Map::from_iter([
6210                        ("id".into(), json!("base")),
6211                        ("title".into(), json!("patch")),
6212                    ])),
6213                }],
6214            )
6215            .expect("an existing base must not read the unavailable visible table");
6216    }
6217
6218    #[test]
6219    fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
6220        for failure in ["fts", "release"] {
6221            let mut client = SyncClient::new(
6222                "replay".into(),
6223                &replay_schema(true),
6224                ClientLimits::default(),
6225            )
6226            .unwrap();
6227            client
6228                .mutate(vec![Mutation::Upsert {
6229                    table: "tasks".into(),
6230                    values: Map::from_iter([
6231                        ("id".into(), json!("queued")),
6232                        ("project_id".into(), json!("p1")),
6233                        ("title".into(), json!("needle")),
6234                        ("done".into(), json!(false)),
6235                    ]),
6236                    base_version: None,
6237                }])
6238                .unwrap();
6239            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
6240            let fts_before = client
6241                .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6242                .unwrap();
6243            let triggers_before: Vec<String> = client
6244                .conn
6245                .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
6246                .unwrap()
6247                .query_map([], |row| row.get(0))
6248                .unwrap()
6249                .collect::<Result<_, _>>()
6250                .unwrap();
6251            if failure == "fts" {
6252                client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
6253            } else {
6254                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();
6255            }
6256            client.overlay_dirty.set(true);
6257            assert!(client.rebuild_overlay().is_err());
6258            assert!(client.overlay_dirty.get());
6259            assert!(client.conn.is_autocommit());
6260            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
6261            assert_eq!(
6262                client
6263                    .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6264                    .unwrap(),
6265                fts_before
6266            );
6267            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();
6268            assert_eq!(original_triggers, triggers_before);
6269            assert_eq!(client.pending_commit_ids().len(), 1);
6270            client
6271                .conn
6272                .execute_batch(if failure == "fts" {
6273                    "DROP TRIGGER fail_fts"
6274                } else {
6275                    "DROP TRIGGER fail_release"
6276                })
6277                .unwrap();
6278            client
6279                .rebuild_overlay()
6280                .expect("retry after removing the fault");
6281        }
6282    }
6283
6284    #[test]
6285    fn secondary_unique_collision_preserves_existing_synced_row() {
6286        let client = SyncClient::new(
6287            "unique-upsert-test".to_owned(),
6288            &json!({
6289                "version": 1,
6290                "tables": [{
6291                    "name": "tasks",
6292                    "primaryKey": "id",
6293                    "columns": [
6294                        { "name": "id", "type": "string", "nullable": false },
6295                        { "name": "project_id", "type": "string", "nullable": false },
6296                        { "name": "title", "type": "string", "nullable": false }
6297                    ],
6298                    "scopes": [{ "pattern": "project:{project_id}" }],
6299                    "indexes": [{
6300                        "name": "tasks_by_project_title",
6301                        "columns": ["project_id", "title"],
6302                        "unique": true
6303                    }]
6304                }]
6305            }),
6306            ClientLimits::default(),
6307        )
6308        .expect("test client");
6309        let table = client.schema.table("tasks").expect("tasks table");
6310        let sql = client.insert_row_sql(&base_table("tasks"), table);
6311
6312        client
6313            .conn
6314            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
6315            .expect("insert first row");
6316        client
6317            .conn
6318            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
6319            .expect("update same primary key");
6320        client
6321            .conn
6322            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
6323            .expect("insert second row");
6324        assert!(client
6325            .conn
6326            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
6327            .is_err());
6328
6329        let rows = client
6330            .conn
6331            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
6332            .expect("prepare rows")
6333            .query_map([], |row| {
6334                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6335            })
6336            .expect("query rows")
6337            .collect::<Result<Vec<_>, _>>()
6338            .expect("collect rows");
6339        assert_eq!(
6340            rows,
6341            vec![
6342                ("t1".to_owned(), "updated".to_owned()),
6343                ("t2".to_owned(), "original".to_owned())
6344            ]
6345        );
6346    }
6347
6348    #[test]
6349    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
6350        let path = std::env::temp_dir().join(format!(
6351            "syncular-startup-intent-{}.db",
6352            uuid::Uuid::new_v4()
6353        ));
6354        let schema = json!({
6355            "version": 1,
6356            "tables": [{
6357                "name": "tasks",
6358                "primaryKey": "id",
6359                "columns": [
6360                    { "name": "id", "type": "string", "nullable": false },
6361                    { "name": "project_id", "type": "string", "nullable": false }
6362                ],
6363                "scopes": [{ "pattern": "project:{project_id}" }]
6364            }]
6365        });
6366
6367        {
6368            let mut first = SyncClient::open_path_with_identity(
6369                None,
6370                &schema,
6371                ClientLimits::default(),
6372                path.to_str().expect("UTF-8 temp path"),
6373            )
6374            .expect("first open");
6375            first
6376                .set_window(
6377                    &WindowBase {
6378                        table: "tasks".to_owned(),
6379                        variable: "project_id".to_owned(),
6380                        fixed_scopes: Vec::new(),
6381                        params: None,
6382                    },
6383                    &["persisted".to_owned()],
6384                )
6385                .expect("persist window");
6386        }
6387
6388        let mut reopened = SyncClient::open_path_with_identity(
6389            None,
6390            &schema,
6391            ClientLimits::default(),
6392            path.to_str().expect("UTF-8 temp path"),
6393        )
6394        .expect("reopen");
6395        assert!(reopened.sync_needed());
6396        assert!(matches!(
6397            reopened.drain_sync_intents().as_slice(),
6398            [SyncIntent::Interactive]
6399        ));
6400        drop(reopened);
6401        std::fs::remove_file(path).expect("remove temp database");
6402    }
6403
6404    #[test]
6405    fn reopening_preserves_immutable_subscription_identity_and_progress() {
6406        let path = std::env::temp_dir().join(format!(
6407            "syncular-subscription-identity-{}.db",
6408            uuid::Uuid::new_v4()
6409        ));
6410        let schema = json!({
6411            "version": 1,
6412            "tables": [
6413                {
6414                    "name": "tasks",
6415                    "primaryKey": "id",
6416                    "columns": [
6417                        { "name": "id", "type": "string", "nullable": false },
6418                        { "name": "project_id", "type": "string", "nullable": false }
6419                    ],
6420                    "scopes": [{ "pattern": "project:{project_id}" }]
6421                },
6422                {
6423                    "name": "docs",
6424                    "primaryKey": "id",
6425                    "columns": [
6426                        { "name": "id", "type": "string", "nullable": false },
6427                        { "name": "org_id", "type": "string", "nullable": false },
6428                        { "name": "project_id", "type": "string", "nullable": false }
6429                    ],
6430                    "scopes": [
6431                        { "pattern": "org:{org_id}" },
6432                        { "pattern": "project:{projectId}", "column": "project_id" }
6433                    ]
6434                }
6435            ]
6436        });
6437
6438        {
6439            let mut first = SyncClient::open_path_with_identity(
6440                None,
6441                &schema,
6442                ClientLimits::default(),
6443                path.to_str().expect("UTF-8 temp path"),
6444            )
6445            .expect("first open");
6446            first
6447                .subscribe(
6448                    "stable-subscription".to_owned(),
6449                    "tasks".to_owned(),
6450                    vec![(
6451                        "project_id".to_owned(),
6452                        vec!["p2".to_owned(), "p1".to_owned()],
6453                    )],
6454                    Some(r#"{"view":"v1"}"#.to_owned()),
6455                )
6456                .expect("persist subscription");
6457            let persisted = {
6458                let subscription = first
6459                    .subs
6460                    .iter_mut()
6461                    .find(|subscription| subscription.id == "stable-subscription")
6462                    .expect("subscription");
6463                subscription.cursor = 41;
6464                subscription.bootstrap_state = Some("resume-token".to_owned());
6465                subscription.effective = Some(vec![(
6466                    "project_id".to_owned(),
6467                    vec!["p1".to_owned(), "p2".to_owned()],
6468                )]);
6469                subscription.synced_once = true;
6470                subscription.clone()
6471            };
6472            first.persist_sub(&persisted).unwrap();
6473        }
6474
6475        let mut reopened = SyncClient::open_path_with_identity(
6476            None,
6477            &schema,
6478            ClientLimits::default(),
6479            path.to_str().expect("UTF-8 temp path"),
6480        )
6481        .expect("reopen");
6482        let progress = reopened
6483            .subscription_state("stable-subscription")
6484            .expect("persisted state");
6485        assert_eq!(progress.cursor, 41);
6486        assert!(progress.has_resume_token);
6487
6488        reopened
6489            .subscribe(
6490                "stable-subscription".to_owned(),
6491                "tasks".to_owned(),
6492                vec![(
6493                    "project_id".to_owned(),
6494                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
6495                )],
6496                Some(r#"{"view":"v1"}"#.to_owned()),
6497            )
6498            .expect("canonical intent is idempotent");
6499        assert_eq!(
6500            reopened
6501                .subscription_state("stable-subscription")
6502                .expect("unchanged state")
6503                .cursor,
6504            progress.cursor
6505        );
6506
6507        for (table, scopes, params) in [
6508            (
6509                "tasks",
6510                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6511                Some(r#"{"view":"v1"}"#.to_owned()),
6512            ),
6513            (
6514                "tasks",
6515                vec![(
6516                    "project_id".to_owned(),
6517                    vec!["p2".to_owned(), "p1".to_owned()],
6518                )],
6519                Some(r#"{"view":"v2"}"#.to_owned()),
6520            ),
6521            (
6522                "docs",
6523                vec![
6524                    ("org_id".to_owned(), vec!["o1".to_owned()]),
6525                    ("projectId".to_owned(), vec!["p1".to_owned()]),
6526                ],
6527                Some(r#"{"view":"v1"}"#.to_owned()),
6528            ),
6529        ] {
6530            let error = reopened
6531                .subscribe(
6532                    "stable-subscription".to_owned(),
6533                    table.to_owned(),
6534                    scopes,
6535                    params,
6536                )
6537                .expect_err("identity rebind must fail");
6538            assert!(error.starts_with("client.subscription_intent_mismatch:"));
6539            assert_eq!(
6540                reopened
6541                    .subscription_state("stable-subscription")
6542                    .expect("unchanged state")
6543                    .cursor,
6544                progress.cursor
6545            );
6546        }
6547
6548        drop(reopened);
6549        std::fs::remove_file(path).expect("remove temp database");
6550    }
6551
6552    #[test]
6553    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
6554        let path = std::env::temp_dir().join(format!(
6555            "syncular-satisfied-schema-floor-{}.db",
6556            uuid::Uuid::new_v4()
6557        ));
6558        let schema = json!({
6559            "version": 23,
6560            "tables": [{
6561                "name": "tasks",
6562                "primaryKey": "id",
6563                "columns": [
6564                    { "name": "id", "type": "string", "nullable": false },
6565                    { "name": "project_id", "type": "string", "nullable": false }
6566                ],
6567                "scopes": [{ "pattern": "project:{project_id}" }]
6568            }]
6569        });
6570
6571        {
6572            let mut first = SyncClient::open_path_with_identity(
6573                None,
6574                &schema,
6575                ClientLimits::default(),
6576                path.to_str().expect("UTF-8 temp path"),
6577            )
6578            .expect("first open");
6579            first
6580                .subscribe(
6581                    "tasks".to_owned(),
6582                    "tasks".to_owned(),
6583                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6584                    None,
6585                )
6586                .expect("persist subscription");
6587            first.set_schema_floor(Some(SchemaFloor {
6588                required_schema_version: Some(22),
6589                latest_schema_version: Some(22),
6590            }));
6591        }
6592
6593        let mut reopened = SyncClient::open_path_with_identity(
6594            None,
6595            &schema,
6596            ClientLimits::default(),
6597            path.to_str().expect("UTF-8 temp path"),
6598        )
6599        .expect("reopen");
6600        assert!(reopened.schema_floor().is_none());
6601        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
6602        assert!(reopened.sync_needed());
6603        assert!(matches!(
6604            reopened.drain_sync_intents().as_slice(),
6605            [SyncIntent::Interactive]
6606        ));
6607        drop(reopened);
6608        std::fs::remove_file(path).expect("remove temp database");
6609    }
6610
6611    #[test]
6612    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
6613        let path = std::env::temp_dir().join(format!(
6614            "syncular-unsatisfied-schema-floor-{}.db",
6615            uuid::Uuid::new_v4()
6616        ));
6617        let schema = json!({
6618            "version": 1,
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                ],
6626                "scopes": [{ "pattern": "project:{project_id}" }]
6627            }]
6628        });
6629
6630        {
6631            let mut first = SyncClient::open_path_with_identity(
6632                None,
6633                &schema,
6634                ClientLimits::default(),
6635                path.to_str().expect("UTF-8 temp path"),
6636            )
6637            .expect("first open");
6638            first
6639                .subscribe(
6640                    "tasks".to_owned(),
6641                    "tasks".to_owned(),
6642                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6643                    None,
6644                )
6645                .expect("persist subscription");
6646            first.set_schema_floor(Some(SchemaFloor {
6647                required_schema_version: Some(2),
6648                latest_schema_version: Some(2),
6649            }));
6650        }
6651
6652        let reopened = SyncClient::open_path_with_identity(
6653            None,
6654            &schema,
6655            ClientLimits::default(),
6656            path.to_str().expect("UTF-8 temp path"),
6657        )
6658        .expect("reopen");
6659        assert_eq!(
6660            reopened.schema_floor(),
6661            Some(&SchemaFloor {
6662                required_schema_version: Some(2),
6663                latest_schema_version: Some(2),
6664            })
6665        );
6666        assert!(!reopened.sync_needed());
6667        drop(reopened);
6668        std::fs::remove_file(path).expect("remove temp database");
6669    }
6670
6671    #[test]
6672    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
6673        let path = std::env::temp_dir().join(format!(
6674            "syncular-indexed-column-bump-{}.db",
6675            uuid::Uuid::new_v4()
6676        ));
6677        let old_schema = json!({
6678            "version": 1,
6679            "tables": [
6680                {
6681                    "name": "tasks",
6682                    "primaryKey": "id",
6683                    "columns": [
6684                        { "name": "id", "type": "string", "nullable": false },
6685                        { "name": "project_id", "type": "string", "nullable": false }
6686                    ],
6687                    "scopes": [{ "pattern": "project:{project_id}" }]
6688                },
6689                {
6690                    "name": "legacy",
6691                    "primaryKey": "id",
6692                    "columns": [
6693                        { "name": "id", "type": "string", "nullable": false },
6694                        { "name": "project_id", "type": "string", "nullable": false }
6695                    ],
6696                    "scopes": [{ "pattern": "project:{project_id}" }]
6697                }
6698            ]
6699        });
6700        {
6701            let mut first = SyncClient::open_path_with_identity(
6702                None,
6703                &old_schema,
6704                ClientLimits::default(),
6705                path.to_str().expect("UTF-8 temp path"),
6706            )
6707            .expect("open old schema");
6708            first
6709                .subscribe(
6710                    "legacy-sub".to_owned(),
6711                    "legacy".to_owned(),
6712                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6713                    None,
6714                )
6715                .expect("persist legacy subscription");
6716        }
6717
6718        let new_schema = json!({
6719            "version": 2,
6720            "tables": [{
6721                "name": "tasks",
6722                "primaryKey": "id",
6723                "columns": [
6724                    { "name": "id", "type": "string", "nullable": false },
6725                    { "name": "project_id", "type": "string", "nullable": false },
6726                    { "name": "facility_membership_id", "type": "string", "nullable": true }
6727                ],
6728                "scopes": [{ "pattern": "project:{project_id}" }],
6729                "indexes": [{
6730                    "name": "tasks_by_membership",
6731                    "columns": ["project_id", "facility_membership_id"],
6732                    "unique": false
6733                }]
6734            }]
6735        });
6736        let upgraded = SyncClient::open_path_with_identity(
6737            None,
6738            &new_schema,
6739            ClientLimits::default(),
6740            path.to_str().expect("UTF-8 temp path"),
6741        )
6742        .expect("open upgraded schema");
6743        let columns = upgraded
6744            .conn
6745            .prepare("PRAGMA table_info(tasks)")
6746            .expect("prepare columns")
6747            .query_map([], |row| row.get::<_, String>(1))
6748            .expect("query columns")
6749            .collect::<Result<Vec<_>, _>>()
6750            .expect("collect columns");
6751        assert!(columns.contains(&"facility_membership_id".to_owned()));
6752        let index_count: i64 = upgraded
6753            .conn
6754            .query_row(
6755                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6756                [],
6757                |row| row.get(0),
6758            )
6759            .expect("query index");
6760        assert_eq!(index_count, 1);
6761        assert!(upgraded.subscription_state("legacy-sub").is_none());
6762        drop(upgraded);
6763        std::fs::remove_file(path).expect("remove temp database");
6764    }
6765
6766    #[test]
6767    fn migrates_pre_envelope_outcome_journal_additively() {
6768        let path = std::env::temp_dir().join(format!(
6769            "syncular-outcome-migration-{}.db",
6770            uuid::Uuid::new_v4()
6771        ));
6772        let conn = Connection::open(&path).expect("open legacy database");
6773        conn.execute_batch(
6774            "CREATE TABLE _syncular_commit_outcomes (
6775               seq INTEGER PRIMARY KEY AUTOINCREMENT,
6776               client_commit_id TEXT NOT NULL UNIQUE,
6777               status TEXT NOT NULL,
6778               recorded_at_ms INTEGER NOT NULL,
6779               results_json TEXT NOT NULL,
6780               resolution TEXT NOT NULL DEFAULT 'active',
6781               resolved_at_ms INTEGER,
6782               replacement_client_commit_id TEXT);",
6783        )
6784        .expect("create legacy outcome journal");
6785        drop(conn);
6786        let schema = json!({
6787            "version": 1,
6788            "tables": [{
6789                "name": "tasks",
6790                "primaryKey": "id",
6791                "columns": [
6792                    { "name": "id", "type": "string", "nullable": false },
6793                    { "name": "project_id", "type": "string", "nullable": false }
6794                ],
6795                "scopes": [{ "pattern": "project:{project_id}" }]
6796            }]
6797        });
6798        let client = SyncClient::open_path(
6799            "migration-native".to_owned(),
6800            &schema,
6801            ClientLimits::default(),
6802            path.to_str().expect("UTF-8 temp path"),
6803        )
6804        .expect("migrate database");
6805        let has_operations = client
6806            .conn
6807            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6808            .expect("prepare table info")
6809            .query_map([], |row| row.get::<_, String>(1))
6810            .expect("query table info")
6811            .filter_map(Result::ok)
6812            .any(|column| column == "operations_json");
6813        assert!(has_operations);
6814        drop(client);
6815        std::fs::remove_file(path).expect("remove temp database");
6816    }
6817
6818    #[test]
6819    fn retained_rejection_survives_file_reopen_and_security_purge() {
6820        let path =
6821            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6822        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6823            { "name": "id", "type": "string", "nullable": false },
6824            { "name": "project_id", "type": "string", "nullable": false },
6825            { "name": "title", "type": "string", "nullable": false }
6826        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6827        {
6828            let mut first = SyncClient::open_path(
6829                "retained-native".to_owned(),
6830                &schema,
6831                ClientLimits::default(),
6832                path.to_str().unwrap(),
6833            )
6834            .unwrap();
6835            first
6836                .write_base_row(
6837                    "tasks",
6838                    &vec![
6839                        Some(ColumnValue::String("t1".into())),
6840                        Some(ColumnValue::String("p1".into())),
6841                        Some(ColumnValue::String("server".into())),
6842                    ],
6843                    1,
6844                )
6845                .unwrap();
6846            first.rebuild_overlay().expect("rebuild overlay");
6847            let operation = OutboxOp {
6848                table: "tasks".into(),
6849                row_id: "t1".into(),
6850                upsert: true,
6851                base_version: Some(1),
6852                values: Some(Map::from_iter([
6853                    ("id".into(), json!("t1")),
6854                    ("title".into(), json!("mine")),
6855                ])),
6856            };
6857            first
6858                .retain_failed_commit("retained", std::slice::from_ref(&operation))
6859                .unwrap();
6860            first
6861                .persist_commit_outcome(
6862                    "retained",
6863                    CommitOutcomeStatus::Rejected,
6864                    &[CommitOperationOutcome::Error {
6865                        rejection: RejectionRecord {
6866                            client_commit_id: "retained".into(),
6867                            op_index: 0,
6868                            code: "validation.denied".into(),
6869                            message: "denied".into(),
6870                            retryable: false,
6871                            details: None,
6872                            operation: None,
6873                        },
6874                    }],
6875                    Some(&[operation]),
6876                )
6877                .unwrap();
6878            first.rebuild_overlay().expect("rebuild overlay");
6879        }
6880        {
6881            let mut reopened = SyncClient::open_path(
6882                "retained-native".to_owned(),
6883                &schema,
6884                ClientLimits::default(),
6885                path.to_str().unwrap(),
6886            )
6887            .unwrap();
6888            let row = reopened
6889                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6890                .expect("read visible row")
6891                .unwrap();
6892            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6893            assert_eq!(
6894                reopened
6895                    .commit_outcome("retained")
6896                    .unwrap()
6897                    .unwrap()
6898                    .retained_rows
6899                    .unwrap()[0]
6900                    .server_row
6901                    .as_ref()
6902                    .unwrap()["title"],
6903                json!("server")
6904            );
6905            reopened
6906                .conn
6907                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6908                .unwrap();
6909            reopened.rebuild_overlay().expect("rebuild overlay");
6910            assert!(reopened
6911                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6912                .expect("read visible row")
6913                .is_none());
6914            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6915            assert!(retained.retained_rows.is_some());
6916            assert_eq!(
6917                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6918                json!("mine")
6919            );
6920            reopened
6921                .purge_local_data(&LocalDataPurgeInput {
6922                    purge_id: "protected-purge".into(),
6923                    targets: vec![LocalDataPurgeTarget {
6924                        table: "tasks".into(),
6925                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6926                    }],
6927                })
6928                .unwrap();
6929            assert!(reopened
6930                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6931                .expect("read visible row")
6932                .is_none());
6933            assert!(reopened
6934                .commit_outcome("retained")
6935                .unwrap()
6936                .unwrap()
6937                .retained_rows
6938                .is_none());
6939        }
6940        std::fs::remove_file(path).unwrap();
6941    }
6942
6943    #[test]
6944    fn durable_conflict_outcome_and_resolution_survive_reopen() {
6945        let path = std::env::temp_dir().join(format!(
6946            "syncular-durable-outcome-{}.db",
6947            uuid::Uuid::new_v4()
6948        ));
6949        let schema = json!({
6950            "version": 1,
6951            "tables": [{
6952                "name": "tasks",
6953                "primaryKey": "id",
6954                "columns": [
6955                    { "name": "id", "type": "string", "nullable": false },
6956                    { "name": "project_id", "type": "string", "nullable": false }
6957                ],
6958                "scopes": [{ "pattern": "project:{project_id}" }]
6959            }]
6960        });
6961        let conflict = ConflictRecord {
6962            client_commit_id: "losing-commit".to_owned(),
6963            op_index: 0,
6964            table: "tasks".to_owned(),
6965            row_id: "t1".to_owned(),
6966            code: "sync.version_conflict".to_owned(),
6967            message: "stale base version".to_owned(),
6968            server_version: 2,
6969            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6970            conflict_columns: vec!["project_id".to_owned()],
6971            operation: Some(CommitOperation {
6972                table: "tasks".to_owned(),
6973                row_id: "t1".to_owned(),
6974                op: "upsert".to_owned(),
6975                base_version: Some(1),
6976                values: Some(Map::from_iter([
6977                    ("id".into(), json!("t1")),
6978                    ("project_id".into(), json!("p1")),
6979                ])),
6980            }),
6981        };
6982        let failed_operations = vec![
6983            OutboxOp {
6984                upsert: true,
6985                table: "tasks".to_owned(),
6986                row_id: "t1".to_owned(),
6987                base_version: Some(1),
6988                values: Some(Map::from_iter([
6989                    ("id".into(), json!("t1")),
6990                    ("project_id".into(), json!("p1")),
6991                ])),
6992            },
6993            OutboxOp {
6994                upsert: true,
6995                table: "tasks".to_owned(),
6996                row_id: "status-event-1".to_owned(),
6997                base_version: Some(0),
6998                values: Some(Map::from_iter([
6999                    ("id".into(), json!("status-event-1")),
7000                    ("project_id".into(), json!("p1")),
7001                ])),
7002            },
7003        ];
7004
7005        {
7006            let mut first = SyncClient::open_path(
7007                "durable-native".to_owned(),
7008                &schema,
7009                ClientLimits::default(),
7010                path.to_str().expect("UTF-8 temp path"),
7011            )
7012            .expect("first open");
7013            first
7014                .begin_observation("test_outcome")
7015                .expect("begin outcome");
7016            first
7017                .persist_commit_outcome(
7018                    "losing-commit",
7019                    CommitOutcomeStatus::Conflict,
7020                    &[CommitOperationOutcome::Conflict {
7021                        conflict: conflict.clone(),
7022                    }],
7023                    Some(&failed_operations),
7024                )
7025                .expect("persist outcome");
7026            first.conflicts.push(conflict);
7027            first
7028                .finish_observation(
7029                    "test_outcome",
7030                    ChangeAccumulator {
7031                        conflicts: true,
7032                        outcomes: true,
7033                        ..ChangeAccumulator::default()
7034                    },
7035                )
7036                .expect("commit outcome");
7037        }
7038
7039        {
7040            let mut reopened = SyncClient::open_path(
7041                "durable-native".to_owned(),
7042                &schema,
7043                ClientLimits::default(),
7044                path.to_str().expect("UTF-8 temp path"),
7045            )
7046            .expect("reopen");
7047            assert_eq!(reopened.conflicts().len(), 1);
7048            assert_eq!(
7049                reopened.conflicts()[0].conflict_columns,
7050                vec!["project_id".to_owned()],
7051                "conflictColumns survives the journal round trip"
7052            );
7053            let outcome = reopened
7054                .commit_outcome("losing-commit")
7055                .expect("read outcome")
7056                .expect("outcome");
7057            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7058            let operations = outcome.operations.expect("aggregate envelope");
7059            assert_eq!(operations.len(), 2);
7060            assert_eq!(operations[1].row_id, "status-event-1");
7061            let resolved = reopened
7062                .resolve_commit_outcome(ResolveCommitOutcomeInput {
7063                    client_commit_id: "losing-commit".to_owned(),
7064                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
7065                    replacement_client_commit_id: None,
7066                })
7067                .expect("resolve");
7068            assert_eq!(
7069                resolved.resolution,
7070                CommitOutcomeResolution::ResolvedKeepServer
7071            );
7072            assert!(reopened.conflicts().is_empty());
7073        }
7074
7075        let reopened = SyncClient::open_path(
7076            "durable-native".to_owned(),
7077            &schema,
7078            ClientLimits::default(),
7079            path.to_str().expect("UTF-8 temp path"),
7080        )
7081        .expect("second reopen");
7082        assert!(reopened.conflicts().is_empty());
7083        assert_eq!(
7084            reopened
7085                .commit_outcome("losing-commit")
7086                .expect("read outcome")
7087                .expect("outcome")
7088                .resolution,
7089            CommitOutcomeResolution::ResolvedKeepServer
7090        );
7091        drop(reopened);
7092        std::fs::remove_file(path).expect("remove temp database");
7093    }
7094
7095    #[test]
7096    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
7097        let path =
7098            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7099        let schema = json!({
7100            "version": 1,
7101            "tables": [{
7102                "name": "tasks",
7103                "primaryKey": "id",
7104                "columns": [
7105                    { "name": "id", "type": "string", "nullable": false },
7106                    { "name": "project_id", "type": "string", "nullable": false }
7107                ],
7108                "scopes": [{ "pattern": "project:{project_id}" }]
7109            }]
7110        });
7111        let mut client = SyncClient::open_path_with_identity(
7112            Some("sidecar-client".to_owned()),
7113            &schema,
7114            ClientLimits::default(),
7115            path.to_str().expect("UTF-8 temp path"),
7116        )
7117        .expect("open owner");
7118        let base = WindowBase {
7119            table: "tasks".to_owned(),
7120            variable: "project_id".to_owned(),
7121            fixed_scopes: Vec::new(),
7122            params: None,
7123        };
7124        client
7125            .set_window(&base, &["one".to_owned()])
7126            .expect("set window");
7127        client
7128            .mutate(vec![Mutation::Upsert {
7129                table: "tasks".to_owned(),
7130                values: Map::from_iter([
7131                    ("id".to_owned(), Value::from("t1")),
7132                    ("project_id".to_owned(), Value::from("one")),
7133                ]),
7134                base_version: None,
7135            }])
7136            .expect("local mutate");
7137
7138        let coverage = [WindowCoverage {
7139            base,
7140            units: vec!["one".to_owned(), "missing".to_owned()],
7141        }];
7142        let owner = client
7143            .query_snapshot(
7144                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7145                &[],
7146                &coverage,
7147                None,
7148            )
7149            .expect("owner snapshot");
7150        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7151        let sidecar = reader
7152            .query_snapshot(
7153                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7154                &[],
7155                &coverage,
7156            )
7157            .expect("sidecar snapshot");
7158
7159        assert_eq!(sidecar.revision, owner.revision);
7160        assert_eq!(sidecar.rows, owner.rows);
7161        assert_eq!(
7162            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
7163            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
7164        );
7165        assert_eq!(sidecar.revision, "2");
7166        assert_eq!(sidecar.rows[0]["id"], "t1");
7167        assert_eq!(sidecar.rows[0]["server_version"], -1);
7168        assert!(!sidecar.coverage.complete);
7169        assert_eq!(sidecar.coverage.pending.len(), 1);
7170        assert_eq!(sidecar.coverage.missing.len(), 1);
7171
7172        drop(reader);
7173        drop(client);
7174        std::fs::remove_file(path).expect("remove temp database");
7175    }
7176
7177    #[test]
7178    fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
7179        let path =
7180            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7181        let schema = json!({
7182            "version": 1,
7183            "tables": [{
7184                "name": "tasks",
7185                "primaryKey": "id",
7186                "columns": [
7187                    { "name": "id", "type": "string", "nullable": false },
7188                    { "name": "project_id", "type": "string", "nullable": false }
7189                ],
7190                "scopes": [{ "pattern": "project:{project_id}" }]
7191            }]
7192        });
7193        let mut client = SyncClient::open_path_with_identity(
7194            Some("sidecar-catchup".to_owned()),
7195            &schema,
7196            ClientLimits::default(),
7197            path.to_str().expect("UTF-8 temp path"),
7198        )
7199        .expect("open owner");
7200        client
7201            .subscribe(
7202                "s1".into(),
7203                "tasks".into(),
7204                vec![("project_id".into(), vec!["one".into()])],
7205                None,
7206            )
7207            .expect("subscribe");
7208        let commit_id = client
7209            .mutate(vec![Mutation::Upsert {
7210                table: "tasks".to_owned(),
7211                values: Map::from_iter([
7212                    ("id".to_owned(), Value::from("t1")),
7213                    ("project_id".to_owned(), Value::from("one")),
7214                ]),
7215                base_version: None,
7216            }])
7217            .expect("mutate");
7218        let revision = client.local_revision().to_string();
7219
7220        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7221        assert_eq!(reader.local_revision().expect("revision"), revision);
7222
7223        let read = reader
7224            .snapshot_read(&SnapshotReadRequest {
7225                statements: vec![
7226                    SnapshotStatement {
7227                        sql: "SELECT 1 AS n",
7228                        params: &[],
7229                    },
7230                    SnapshotStatement {
7231                        sql: "SELECT id FROM tasks ORDER BY id",
7232                        params: &[],
7233                    },
7234                ],
7235                coverage: &[],
7236                subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
7237                commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
7238            })
7239            .expect("snapshot read");
7240        assert_eq!(read.revision, revision);
7241        assert_eq!(read.queries.len(), 2);
7242        assert_eq!(read.queries[0][0]["n"], 1);
7243        assert_eq!(read.queries[1][0]["id"], "t1");
7244        match &read.subscriptions[0] {
7245            SubscriptionCatchup::Known {
7246                status,
7247                cursor,
7248                bootstrap_complete,
7249                known_pending_pages,
7250                ..
7251            } => {
7252                assert_eq!(status, "active");
7253                assert_eq!(*cursor, -1);
7254                assert!(!bootstrap_complete);
7255                assert!(known_pending_pages);
7256            }
7257            other => panic!("expected a known subscription: {other:?}"),
7258        }
7259        assert!(matches!(
7260            &read.subscriptions[1],
7261            SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
7262        ));
7263        assert!(matches!(
7264            &read.deliveries[0],
7265            CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
7266        ));
7267        assert!(matches!(
7268            &read.deliveries[1],
7269            CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
7270        ));
7271
7272        // A bad statement rolls the whole read back and releases the connection.
7273        assert!(reader
7274            .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
7275            .is_err());
7276        assert_eq!(
7277            reader.local_revision().expect("revision after rollback"),
7278            revision
7279        );
7280        // The read-only guard refuses a write on any statement.
7281        assert!(reader
7282            .query_snapshot("DELETE FROM tasks", &[], &[])
7283            .is_err());
7284
7285        // A corrupt revision marker fails typed, not as an invented zero.
7286        client
7287            .conn
7288            .execute(
7289                "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
7290                [],
7291            )
7292            .expect("corrupt revision");
7293        let failure = reader.local_revision().expect_err("corrupt revision fails");
7294        assert_eq!(failure.code, Some("sync.local_corrupt"));
7295
7296        drop(reader);
7297        drop(client);
7298        std::fs::remove_file(path).expect("remove temp database");
7299    }
7300
7301    #[test]
7302    fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
7303        let path =
7304            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7305        let schema = json!({
7306            "version": 1,
7307            "tables": [{
7308                "name": "tasks",
7309                "primaryKey": "id",
7310                "columns": [
7311                    { "name": "id", "type": "string", "nullable": false },
7312                    { "name": "project_id", "type": "string", "nullable": false }
7313                ],
7314                "scopes": [{ "pattern": "project:{project_id}" }]
7315            }]
7316        });
7317        let mut client = SyncClient::open_path_with_identity(
7318            Some("sidecar-delivery".to_owned()),
7319            &schema,
7320            ClientLimits::default(),
7321            path.to_str().expect("UTF-8 temp path"),
7322        )
7323        .expect("open owner");
7324        client.begin_observation("test_delivery").expect("observe");
7325        let operations = [OutboxOp {
7326            upsert: true,
7327            table: "tasks".to_owned(),
7328            row_id: "t1".to_owned(),
7329            base_version: Some(1),
7330            values: Some(Map::from_iter([
7331                ("id".into(), json!("t1")),
7332                ("project_id".into(), json!("p1")),
7333            ])),
7334        }];
7335        client
7336            .persist_commit_outcome(
7337                "conflict-commit",
7338                CommitOutcomeStatus::Conflict,
7339                &[CommitOperationOutcome::Conflict {
7340                    conflict: ConflictRecord {
7341                        client_commit_id: "conflict-commit".to_owned(),
7342                        op_index: 0,
7343                        table: "tasks".to_owned(),
7344                        row_id: "t1".to_owned(),
7345                        code: "sync.version_conflict".to_owned(),
7346                        message: "stale base version".to_owned(),
7347                        server_version: 2,
7348                        server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
7349                        conflict_columns: vec!["project_id".to_owned()],
7350                        operation: None,
7351                    },
7352                }],
7353                Some(&operations),
7354            )
7355            .expect("persist conflict");
7356        client
7357            .persist_commit_outcome(
7358                "rejected-commit",
7359                CommitOutcomeStatus::Rejected,
7360                &[CommitOperationOutcome::Error {
7361                    rejection: RejectionRecord {
7362                        client_commit_id: "rejected-commit".to_owned(),
7363                        op_index: 0,
7364                        code: "sync.unknown_table".to_owned(),
7365                        message: "unknown table".to_owned(),
7366                        retryable: false,
7367                        details: None,
7368                        operation: None,
7369                    },
7370                }],
7371                Some(&operations),
7372            )
7373            .expect("persist rejection");
7374        client
7375            .finish_observation(
7376                "test_delivery",
7377                ChangeAccumulator {
7378                    conflicts: true,
7379                    outcomes: true,
7380                    ..ChangeAccumulator::default()
7381                },
7382            )
7383            .expect("commit outcomes");
7384
7385        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7386        match reader.commit_delivery("conflict-commit").expect("conflict") {
7387            CommitDelivery::Known { outcome, .. } => {
7388                assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7389                assert!(matches!(
7390                    outcome.results.as_slice(),
7391                    [CommitOperationOutcome::Conflict { conflict }]
7392                        if conflict.code == "sync.version_conflict"
7393                            && conflict.server_version == 2
7394                ));
7395                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7396                assert!(
7397                    outcome.retained_rows.is_none(),
7398                    "the sidecar reports the persisted view, not owner-derived retained rows"
7399                );
7400            }
7401            other => panic!("expected a known conflict: {other:?}"),
7402        }
7403        match reader
7404            .commit_delivery("rejected-commit")
7405            .expect("rejection")
7406        {
7407            CommitDelivery::Known { outcome, .. } => {
7408                assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
7409                assert!(matches!(
7410                    outcome.results.as_slice(),
7411                    [CommitOperationOutcome::Error { rejection }]
7412                        if rejection.code == "sync.unknown_table"
7413                ));
7414                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7415                assert!(outcome.retained_rows.is_none());
7416            }
7417            other => panic!("expected a known rejection: {other:?}"),
7418        }
7419        drop(reader);
7420        drop(client);
7421        std::fs::remove_file(path).expect("remove temp database");
7422    }
7423
7424    #[test]
7425    fn snapshot_read_isolates_a_concurrent_writer_commit() {
7426        use std::sync::mpsc::channel;
7427        let path = std::env::temp_dir().join(format!(
7428            "syncular-read-concurrent-{}.db",
7429            uuid::Uuid::new_v4()
7430        ));
7431        let schema = json!({
7432            "version": 1,
7433            "tables": [{
7434                "name": "tasks",
7435                "primaryKey": "id",
7436                "columns": [
7437                    { "name": "id", "type": "string", "nullable": false },
7438                    { "name": "project_id", "type": "string", "nullable": false }
7439                ],
7440                "scopes": [{ "pattern": "project:{project_id}" }]
7441            }]
7442        });
7443        let mut client = SyncClient::open_path_with_identity(
7444            Some("sidecar-concurrent".to_owned()),
7445            &schema,
7446            ClientLimits::default(),
7447            path.to_str().expect("UTF-8 temp path"),
7448        )
7449        .expect("open owner");
7450        client
7451            .mutate(vec![Mutation::Upsert {
7452                table: "tasks".to_owned(),
7453                values: Map::from_iter([
7454                    ("id".to_owned(), Value::from("t1")),
7455                    ("project_id".to_owned(), Value::from("one")),
7456                ]),
7457                base_version: None,
7458            }])
7459            .expect("seed row");
7460        let revision_before = client.local_revision().to_string();
7461
7462        // A writer on a second connection commits while the reader is between
7463        // statements; the hook rendezvous is a barrier, not a sleep.
7464        let (ready_tx, ready_rx) = channel::<()>();
7465        let (done_tx, done_rx) = channel::<()>();
7466        let writer_path = path.to_string_lossy().into_owned();
7467        let writer_schema = schema.clone();
7468        let writer = std::thread::spawn(move || {
7469            let mut writer = SyncClient::open_path_with_identity(
7470                Some("sidecar-concurrent".to_owned()),
7471                &writer_schema,
7472                ClientLimits::default(),
7473                &writer_path,
7474            )
7475            .expect("open writer");
7476            ready_rx.recv().expect("reader signalled");
7477            writer
7478                .mutate(vec![Mutation::Upsert {
7479                    table: "tasks".to_owned(),
7480                    values: Map::from_iter([
7481                        ("id".to_owned(), Value::from("t2")),
7482                        ("project_id".to_owned(), Value::from("one")),
7483                    ]),
7484                    base_version: None,
7485                }])
7486                .expect("writer commit");
7487            let _ = done_tx.send(());
7488        });
7489        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7490        SNAPSHOT_READ_HOOK.with(|slot| {
7491            *slot.borrow_mut() = Some(Box::new(move || {
7492                ready_tx.send(()).expect("signal writer");
7493                done_rx.recv().expect("writer finished");
7494            }));
7495        });
7496        let read = reader
7497            .snapshot_read(&SnapshotReadRequest {
7498                statements: vec![
7499                    SnapshotStatement {
7500                        sql: "SELECT count(*) AS n FROM tasks",
7501                        params: &[],
7502                    },
7503                    SnapshotStatement {
7504                        sql: "SELECT count(*) AS n FROM tasks",
7505                        params: &[],
7506                    },
7507                ],
7508                coverage: &[],
7509                subscriptions: Vec::new(),
7510                commit_ids: Vec::new(),
7511            })
7512            .expect("snapshot read");
7513        assert_eq!(
7514            read.queries[0][0]["n"], 1,
7515            "first statement sees the pre-commit row"
7516        );
7517        assert_eq!(
7518            read.queries[1][0]["n"], 1,
7519            "a later statement keeps the one read snapshot"
7520        );
7521        assert_eq!(
7522            read.revision, revision_before,
7523            "the read reports the snapshot revision, not the writer's"
7524        );
7525        writer.join().expect("join writer");
7526
7527        assert_ne!(
7528            reader.local_revision().expect("revision after commit"),
7529            revision_before,
7530            "the next read observes the committed revision"
7531        );
7532        let after = reader
7533            .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
7534            .expect("count after commit");
7535        assert_eq!(
7536            after.rows[0]["n"], 2,
7537            "the committed row is visible afterwards"
7538        );
7539
7540        drop(reader);
7541        drop(client);
7542        std::fs::remove_file(path).expect("remove temp database");
7543    }
7544
7545    #[test]
7546    fn snapshot_read_rejects_every_non_canonical_revision() {
7547        let path = std::env::temp_dir().join(format!(
7548            "syncular-read-revision-{}.db",
7549            uuid::Uuid::new_v4()
7550        ));
7551        let schema = json!({
7552            "version": 1,
7553            "tables": [{
7554                "name": "tasks",
7555                "primaryKey": "id",
7556                "columns": [
7557                    { "name": "id", "type": "string", "nullable": false },
7558                    { "name": "project_id", "type": "string", "nullable": false }
7559                ],
7560                "scopes": [{ "pattern": "project:{project_id}" }]
7561            }]
7562        });
7563        let client = SyncClient::open_path_with_identity(
7564            Some("sidecar-revision".to_owned()),
7565            &schema,
7566            ClientLimits::default(),
7567            path.to_str().expect("UTF-8 temp path"),
7568        )
7569        .expect("open owner");
7570        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7571        for bad in [
7572            "+1",
7573            "01",
7574            "-1",
7575            " 1",
7576            "1 ",
7577            "18446744073709551616",
7578            "1.0",
7579            "abc",
7580            "",
7581        ] {
7582            client
7583                .conn
7584                .execute(
7585                    "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
7586                    rusqlite::params![bad],
7587                )
7588                .expect("corrupt revision");
7589            let failure = reader.local_revision().expect_err("corrupt marker fails");
7590            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
7591        }
7592        for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
7593            client
7594                .conn
7595                .execute_batch(&format!(
7596                    "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
7597                ))
7598                .expect("corrupt stored revision type");
7599            assert_eq!(
7600                reader.local_revision().expect_err("invalid SQL field").code,
7601                Some("sync.local_corrupt")
7602            );
7603        }
7604        // The absent marker is the legacy zero, not corruption.
7605        client
7606            .conn
7607            .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
7608            .expect("delete marker");
7609        assert_eq!(reader.local_revision().expect("legacy zero"), "0");
7610
7611        drop(reader);
7612        drop(client);
7613        std::fs::remove_file(path).expect("remove temp database");
7614    }
7615
7616    #[test]
7617    fn snapshot_read_rejects_corrupt_subscription_state() {
7618        let path = std::env::temp_dir().join(format!(
7619            "syncular-read-substate-{}.db",
7620            uuid::Uuid::new_v4()
7621        ));
7622        let schema = json!({
7623            "version": 1,
7624            "tables": [{
7625                "name": "tasks",
7626                "primaryKey": "id",
7627                "columns": [
7628                    { "name": "id", "type": "string", "nullable": false },
7629                    { "name": "project_id", "type": "string", "nullable": false }
7630                ],
7631                "scopes": [{ "pattern": "project:{project_id}" }]
7632            }]
7633        });
7634        let mut client = SyncClient::open_path_with_identity(
7635            Some("sidecar-substate".to_owned()),
7636            &schema,
7637            ClientLimits::default(),
7638            path.to_str().expect("UTF-8 temp path"),
7639        )
7640        .expect("open owner");
7641        client
7642            .subscribe(
7643                "s1".into(),
7644                "tasks".into(),
7645                vec![("project_id".into(), vec!["one".into()])],
7646                None,
7647            )
7648            .expect("subscribe");
7649        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7650        for bad in [
7651            "not json".to_owned(),
7652            "{}".to_owned(),
7653            json!({ "status": "bogus", "cursor": 0 }).to_string(),
7654            json!({ "status": "active", "cursor": "x" }).to_string(),
7655            json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
7656            json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
7657        ] {
7658            client
7659                .conn
7660                .execute(
7661                    "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
7662                    rusqlite::params![bad],
7663                )
7664                .expect("corrupt subscription state");
7665            let failure = reader
7666                .subscription_catchup("s1")
7667                .expect_err("corrupt state fails");
7668            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
7669        }
7670
7671        for assignment in [
7672            "tbl=X'7461736b73'",
7673            "state_json=X'7b7d'",
7674            "state_json=CAST(X'FF' AS TEXT)",
7675        ] {
7676            client.conn.execute_batch(&format!(
7677                "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
7678            )).expect("corrupt stored subscription type");
7679            assert_eq!(
7680                reader
7681                    .subscription_catchup("s1")
7682                    .expect_err("invalid SQL field")
7683                    .code,
7684                Some("sync.local_corrupt")
7685            );
7686        }
7687
7688        drop(reader);
7689        drop(client);
7690        std::fs::remove_file(path).expect("remove temp database");
7691    }
7692
7693    #[test]
7694    fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
7695        let path = std::env::temp_dir().join(format!(
7696            "syncular-read-rollback-{}.db",
7697            uuid::Uuid::new_v4()
7698        ));
7699        let schema = json!({
7700            "version": 1,
7701            "tables": [{
7702                "name": "tasks",
7703                "primaryKey": "id",
7704                "columns": [
7705                    { "name": "id", "type": "string", "nullable": false },
7706                    { "name": "project_id", "type": "string", "nullable": false }
7707                ],
7708                "scopes": [{ "pattern": "project:{project_id}" }]
7709            }]
7710        });
7711        let mut client = SyncClient::open_path_with_identity(
7712            Some("sidecar-rollback".to_owned()),
7713            &schema,
7714            ClientLimits::default(),
7715            path.to_str().expect("UTF-8 temp path"),
7716        )
7717        .expect("open owner");
7718        let commit_id = client
7719            .mutate(vec![Mutation::Upsert {
7720                table: "tasks".to_owned(),
7721                values: Map::from_iter([
7722                    ("id".to_owned(), Value::from("t1")),
7723                    ("project_id".to_owned(), Value::from("one")),
7724                ]),
7725                base_version: None,
7726            }])
7727            .expect("seed row");
7728        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7729        let revision = reader.local_revision().expect("revision");
7730
7731        // The second statement fails: the whole read rolls back and the
7732        // connection releases, so the same reader still works afterwards.
7733        let failure = reader
7734            .snapshot_read(&SnapshotReadRequest {
7735                statements: vec![
7736                    SnapshotStatement {
7737                        sql: "SELECT id FROM tasks",
7738                        params: &[],
7739                    },
7740                    SnapshotStatement {
7741                        sql: "SELECT * FROM does_not_exist",
7742                        params: &[],
7743                    },
7744                ],
7745                coverage: &[],
7746                subscriptions: Vec::new(),
7747                commit_ids: vec![commit_id.clone()],
7748            })
7749            .expect_err("second statement fails");
7750        assert_eq!(failure.code, None);
7751        assert_eq!(
7752            reader.local_revision().expect("revision after rollback"),
7753            revision,
7754            "a rolled-back read consumes no revision"
7755        );
7756        assert!(
7757            !matches!(
7758                reader
7759                    .commit_delivery(&commit_id)
7760                    .expect("delivery after rollback"),
7761                CommitDelivery::Unknown { .. }
7762            ),
7763            "the same reader serves the next read after releasing the connection"
7764        );
7765
7766        // The read-only guard rejects a write in the first position too.
7767        let guard = reader
7768            .snapshot_read(&SnapshotReadRequest {
7769                statements: vec![SnapshotStatement {
7770                    sql: "DELETE FROM tasks",
7771                    params: &[],
7772                }],
7773                coverage: &[],
7774                subscriptions: Vec::new(),
7775                commit_ids: Vec::new(),
7776            })
7777            .expect_err("write guard");
7778        assert!(guard.message.contains("read-only"), "{}", guard.message);
7779
7780        drop(reader);
7781        drop(client);
7782        std::fs::remove_file(path).expect("remove temp database");
7783    }
7784
7785    #[test]
7786    fn owned_query_failures_classify_order_bound_and_clear() {
7787        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7788            rusqlite::ffi::Error::new(266),
7789            Some("private SQLite prose".to_owned()),
7790        ));
7791        assert_eq!(io.code, Some("client.storage_io"));
7792        assert_eq!(io.sqlite_code, Some(266));
7793        assert_eq!(io.message, "local SQLite storage I/O failed");
7794        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7795            rusqlite::ffi::Error::new(26),
7796            None,
7797        ));
7798        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7799        assert!(!io.retryable());
7800        assert!(!corrupt.retryable());
7801
7802        // SQLITE_BUSY (5) and SQLITE_LOCKED (6), including extended codes, are
7803        // transient lock contention: classified busy and retryable.
7804        for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7805            let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7806                rusqlite::ffi::Error::new(raw),
7807                Some("database is locked".to_owned()),
7808            ));
7809            assert_eq!(busy.code, Some("client.storage_busy"));
7810            assert_eq!(busy.sqlite_code, Some(extended));
7811            assert_eq!(busy.message, "local SQLite storage is busy");
7812            assert!(busy.retryable());
7813        }
7814        let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7815            rusqlite::ffi::Error::new(1),
7816            Some("generic SQLite prose".to_owned()),
7817        ));
7818        assert_eq!(unrelated.code, None);
7819        assert!(!unrelated.retryable());
7820
7821        let mut client = client();
7822        let generic = QueryReadFailure::from("private query prose".to_owned());
7823        client.record_query_read(
7824            QueryOwner {
7825                id: "first",
7826                tables: &["tasks", "tasks"],
7827            },
7828            Some(&generic),
7829        );
7830        client.record_query_read(
7831            QueryOwner {
7832                id: "second",
7833                tables: &["tasks"],
7834            },
7835            Some(&generic),
7836        );
7837        client.record_query_read(
7838            QueryOwner {
7839                id: "first",
7840                tables: &["docs"],
7841            },
7842            Some(&io),
7843        );
7844        let failures = &client
7845            .diagnostics_snapshot(&Default::default())
7846            .expect("diagnostics")
7847            .query_failures;
7848        assert_eq!(
7849            failures
7850                .iter()
7851                .map(|entry| entry.id.as_str())
7852                .collect::<Vec<_>>(),
7853            vec!["second", "first"]
7854        );
7855        assert_eq!(failures[1].tables, vec!["docs"]);
7856        assert_eq!(failures[1].code, "client.storage_io");
7857
7858        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7859            client.record_query_read(
7860                QueryOwner {
7861                    id: &format!("query-{index}"),
7862                    tables: &["tasks"],
7863                },
7864                Some(&generic),
7865            );
7866        }
7867        let failures = &client
7868            .diagnostics_snapshot(&Default::default())
7869            .expect("bounded diagnostics")
7870            .query_failures;
7871        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7872        assert_eq!(failures.last().expect("last failure").id, "query-256");
7873
7874        client
7875            .query_snapshot(
7876                "SELECT id FROM tasks",
7877                &[],
7878                &[],
7879                Some(QueryOwner {
7880                    id: "query-256",
7881                    tables: &["tasks"],
7882                }),
7883            )
7884            .expect("successful owned read");
7885        assert!(client
7886            .diagnostics_snapshot(&Default::default())
7887            .expect("cleared diagnostics")
7888            .query_failures
7889            .iter()
7890            .all(|entry| entry.id != "query-256"));
7891    }
7892
7893    #[test]
7894    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7895        let mut client = client();
7896        let image = Connection::open_in_memory().unwrap();
7897        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7898            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7899            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7900            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7901            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7902        let table = client.schema.table("tasks").unwrap().clone();
7903        let scopes = vec![("project_id".into(), vec!["p1".into()])];
7904        assert!(client
7905            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7906            .is_err());
7907        assert!(client.conn.is_autocommit());
7908        assert_eq!(
7909            client
7910                .query("SELECT count(*) AS n FROM tasks", &[])
7911                .unwrap()[0]["n"],
7912            1024
7913        );
7914        assert_eq!(client.drain_change_batches().len(), 1);
7915        image
7916            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7917            .unwrap();
7918        assert!(matches!(
7919            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7920            Ok(2050)
7921        ));
7922        assert_eq!(
7923            client
7924                .query("SELECT count(*) AS n FROM tasks", &[])
7925                .unwrap()[0]["n"],
7926            2050
7927        );
7928        assert_eq!(client.drain_change_batches().len(), 3);
7929    }
7930
7931    #[test]
7932    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7933        let mut client = client();
7934        let base = WindowBase {
7935            table: "tasks".into(),
7936            variable: "project_id".into(),
7937            fixed_scopes: Vec::new(),
7938            params: None,
7939        };
7940        client
7941            .set_window(&base, &["p1".into(), "p2".into()])
7942            .unwrap();
7943        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7944            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7945            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7946            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7947            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7948        client.drain_change_batches();
7949        assert!(client.set_window(&base, &["p2".into()]).is_err());
7950        assert!(client.conn.is_autocommit());
7951        assert_eq!(
7952            client
7953                .query("SELECT count(*) AS n FROM tasks", &[])
7954                .unwrap()[0]["n"],
7955            1027
7956        );
7957        assert_eq!(client.window_state(&base).units, vec!["p2"]);
7958        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7959        assert_eq!(client.drain_change_batches().len(), 1);
7960        client
7961            .conn
7962            .execute_batch("DROP TRIGGER interrupt_evict")
7963            .unwrap();
7964        client.drain_pending_evictions().unwrap();
7965        assert!(client.load_pending_evictions().unwrap().is_empty());
7966        assert_eq!(
7967            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7968            "held"
7969        );
7970        assert_eq!(client.drain_change_batches().len(), 2);
7971    }
7972
7973    #[test]
7974    fn eviction_marker_failures_roll_back_window_changes() {
7975        let mut client = client();
7976        let base = WindowBase {
7977            table: "tasks".into(),
7978            variable: "project_id".into(),
7979            fixed_scopes: Vec::new(),
7980            params: None,
7981        };
7982        client.set_window(&base, &["p1".into()]).unwrap();
7983        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7984            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7985            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7986            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7987        let revision = client.local_revision();
7988        assert!(client.set_window(&base, &[]).is_err());
7989        assert!(client.conn.is_autocommit());
7990        assert_eq!(client.local_revision(), revision);
7991        assert_eq!(
7992            client
7993                .query("SELECT count(*) AS n FROM tasks", &[])
7994                .unwrap()[0]["n"],
7995            2050
7996        );
7997        assert_eq!(client.window_state(&base).units, vec!["p1"]);
7998        client.conn.execute_batch("DROP TRIGGER fail_marker;
7999            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
8000        assert!(client.set_window(&base, &[]).is_err());
8001        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
8002            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
8003        assert!(client.drain_pending_evictions().is_err());
8004        assert!(client.conn.is_autocommit());
8005        assert_eq!(
8006            client
8007                .query("SELECT count(*) AS n FROM tasks", &[])
8008                .unwrap()[0]["n"],
8009            2
8010        );
8011        assert!(client.set_window(&base, &["p1".into()]).is_err());
8012        assert!(client.conn.is_autocommit());
8013        assert!(client.window_state(&base).units.is_empty());
8014        assert!(client.subs.is_empty());
8015        assert_eq!(
8016            client
8017                .query(
8018                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
8019                    &[]
8020                )
8021                .unwrap()[0]["n"],
8022            1
8023        );
8024        client
8025            .conn
8026            .execute_batch("DROP TRIGGER fail_marker")
8027            .unwrap();
8028        client.set_window(&base, &["p1".into()]).unwrap();
8029        assert_eq!(client.window_state(&base).units, vec!["p1"]);
8030    }
8031
8032    #[test]
8033    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
8034        let mut client = client();
8035        client
8036            .conn
8037            .execute(
8038                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
8039                [],
8040            )
8041            .unwrap();
8042        assert_eq!(
8043            client.drain_pending_evictions().unwrap_err(),
8044            "sync.local_corrupt: invalid pending eviction scopes"
8045        );
8046        assert!(client.conn.is_autocommit());
8047        assert!(client.enqueue_startup_sync_if_needed().is_err());
8048    }
8049
8050    #[test]
8051    fn image_chunks_reject_null_primary_keys() {
8052        let mut client = client();
8053        let image = Connection::open_in_memory().unwrap();
8054        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8055            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
8056            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
8057            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8058            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
8059        let table = client.schema.table("tasks").unwrap().clone();
8060        assert!(client
8061            .apply_sqlite_image(
8062                &image,
8063                &table,
8064                true,
8065                &[("project_id".into(), vec!["p1".into()])],
8066                1025,
8067                7,
8068                "digest"
8069            )
8070            .is_err());
8071        assert!(client.conn.is_autocommit());
8072        assert_eq!(
8073            client
8074                .query("SELECT count(*) AS n FROM tasks", &[])
8075                .unwrap()[0]["n"],
8076            0
8077        );
8078    }
8079
8080    #[test]
8081    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
8082        for images in [false, true] {
8083            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8084                "name":name,"primaryKey":"id","columns":[
8085                    {"name":"id","type":"string","nullable":false},
8086                    {"name":"project_id","type":"string","nullable":false},
8087                    {"name":"title","type":"string","nullable":false}],
8088                "scopes":[{"pattern":"project:{project_id}"}],
8089                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
8090                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8091            })))});
8092            let mut actual =
8093                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
8094            let mut reference =
8095                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
8096            let scopes = vec![("project_id".into(), vec!["p1".into()])];
8097            for client in [&mut actual, &mut reference] {
8098                client
8099                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
8100                    .unwrap();
8101                client.subs[0].effective = Some(scopes.clone());
8102                for (table, id, title) in [
8103                    ("tasks", "a", "original"),
8104                    ("tasks", "b", "occupied"),
8105                    ("tasks", "c", "deleted"),
8106                    ("notes", "n", "untouched"),
8107                ] {
8108                    client
8109                        .write_base_row(
8110                            table,
8111                            &vec![
8112                                Some(ColumnValue::String(id.into())),
8113                                Some(ColumnValue::String("p1".into())),
8114                                Some(ColumnValue::String(title.into())),
8115                            ],
8116                            1,
8117                        )
8118                        .unwrap();
8119                }
8120                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8121                client
8122                    .mutate(vec![
8123                        Mutation::Upsert {
8124                            table: "tasks".into(),
8125                            values: Map::from_iter([
8126                                ("id".into(), Value::from("a")),
8127                                ("project_id".into(), Value::from("p1")),
8128                                ("title".into(), Value::from("available")),
8129                            ]),
8130                            base_version: None,
8131                        },
8132                        Mutation::Delete {
8133                            table: "tasks".into(),
8134                            row_id: "c".into(),
8135                            base_version: None,
8136                        },
8137                        Mutation::Upsert {
8138                            table: "tasks".into(),
8139                            values: Map::from_iter([
8140                                ("id".into(), Value::from("local")),
8141                                ("project_id".into(), Value::from("p1")),
8142                                ("title".into(), Value::from("local value")),
8143                            ]),
8144                            base_version: None,
8145                        },
8146                        Mutation::Upsert {
8147                            table: "notes".into(),
8148                            values: Map::from_iter([
8149                                ("id".into(), Value::from("n")),
8150                                ("project_id".into(), Value::from("p1")),
8151                                ("title".into(), Value::from("pending note")),
8152                            ]),
8153                            base_version: None,
8154                        },
8155                    ])
8156                    .unwrap();
8157            }
8158            let pending = actual.pending_commit_ids();
8159            actual.overlay_rebuild_count.set(0);
8160            for (id, title) in [
8161                ("b", "available"),
8162                ("b", "occupied"),
8163                ("a", "server original"),
8164                ("d", "local value"),
8165                ("d", "released"),
8166                ("c", "redelivered"),
8167            ] {
8168                let table = actual.schema.table("tasks").unwrap().clone();
8169                let values = vec![
8170                    Some(ColumnValue::String(id.into())),
8171                    Some(ColumnValue::String("p1".into())),
8172                    Some(ColumnValue::String(title.into())),
8173                ];
8174                if images {
8175                    let image = Connection::open_in_memory().unwrap();
8176                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8177                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8178                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8179                    image
8180                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
8181                        .unwrap();
8182                    assert!(matches!(
8183                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
8184                        Ok(1)
8185                    ));
8186                } else {
8187                    let segment = RowsSegment {
8188                        table: "tasks".into(),
8189                        schema_version: 1,
8190                        columns: table.wire_columns.clone(),
8191                        blocks: vec![vec![ssp2::segment::SegmentRow {
8192                            server_version: 2,
8193                            values: values.clone(),
8194                        }]],
8195                    };
8196                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
8197                }
8198                reference.write_base_row("tasks", &values, 2).unwrap();
8199                reference.rebuild_overlay().expect("rebuild overlay");
8200                for table in ["tasks", "notes"] {
8201                    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")] {
8202                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
8203                    }
8204                }
8205                assert_eq!(actual.pending_commit_ids(), pending);
8206            }
8207            assert_eq!(
8208                actual.overlay_rebuild_count.get(),
8209                0,
8210                "each chunk must leave unrelated tables alone"
8211            );
8212            let before = actual
8213                .query("SELECT * FROM tasks ORDER BY id", &[])
8214                .unwrap();
8215            let fts_before = actual
8216                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8217                .unwrap();
8218            let revision = actual.local_revision();
8219            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
8220            let table = actual.schema.table("tasks").unwrap().clone();
8221            let segment = RowsSegment {
8222                table: "tasks".into(),
8223                schema_version: 1,
8224                columns: table.wire_columns.clone(),
8225                blocks: vec![vec![ssp2::segment::SegmentRow {
8226                    server_version: 3,
8227                    values: vec![
8228                        Some(ColumnValue::String("b".into())),
8229                        Some(ColumnValue::String("p1".into())),
8230                        Some(ColumnValue::String("fresh".into())),
8231                    ],
8232                }]],
8233            };
8234            assert!(actual.apply_segment(0, &segment, false).is_err());
8235            assert!(actual.conn.is_autocommit());
8236            assert_eq!(
8237                actual
8238                    .query("SELECT * FROM tasks ORDER BY id", &[])
8239                    .unwrap(),
8240                before
8241            );
8242            assert_eq!(
8243                actual
8244                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8245                    .unwrap(),
8246                fts_before
8247            );
8248            assert_eq!(actual.local_revision(), revision);
8249            assert_eq!(actual.pending_commit_ids(), pending);
8250        }
8251    }
8252
8253    #[test]
8254    fn import_reconciliation_preserves_typed_pending_primary_keys() {
8255        for (kind, a, b) in [
8256            ("string", json!("a"), json!("b")),
8257            ("integer", json!(1), json!(2)),
8258            ("boolean", json!(true), json!(false)),
8259            ("json", json!("1"), json!("2")),
8260        ] {
8261            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}]}]});
8262            for images in [false, true] {
8263                let mut client =
8264                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
8265                let table = client.schema.table("tasks").unwrap().clone();
8266                client
8267                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
8268                    .unwrap();
8269                for (id, title) in [(&a, "original"), (&b, "occupied")] {
8270                    client
8271                        .write_base_row(
8272                            "tasks",
8273                            &vec![
8274                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
8275                                Some(ColumnValue::String(title.into())),
8276                            ],
8277                            1,
8278                        )
8279                        .unwrap();
8280                }
8281                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8282                client
8283                    .mutate(vec![Mutation::Upsert {
8284                        table: "tasks".into(),
8285                        values: Map::from_iter([
8286                            ("id".into(), a.clone()),
8287                            ("title".into(), json!("available")),
8288                        ]),
8289                        base_version: None,
8290                    }])
8291                    .unwrap();
8292                client.overlay_rebuild_count.set(0);
8293                for (title, expected) in [("available", "original"), ("occupied", "available")] {
8294                    let values = vec![
8295                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
8296                        Some(ColumnValue::String(title.into())),
8297                    ];
8298                    if images {
8299                        let image = Connection::open_in_memory().unwrap();
8300                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8301                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8302                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8303                        image
8304                            .execute(
8305                                "INSERT INTO tasks VALUES (?1,?2,2)",
8306                                rusqlite::params![RowParam::Cell(&values[0]), title],
8307                            )
8308                            .unwrap();
8309                        assert!(
8310                            matches!(
8311                                client.apply_sqlite_image(
8312                                    &image,
8313                                    &table,
8314                                    false,
8315                                    &[],
8316                                    1,
8317                                    7,
8318                                    "digest"
8319                                ),
8320                                Ok(1)
8321                            ),
8322                            "{kind}, images={images}"
8323                        );
8324                    } else {
8325                        let segment = RowsSegment {
8326                            table: "tasks".into(),
8327                            schema_version: 1,
8328                            columns: table.wire_columns.clone(),
8329                            blocks: vec![vec![ssp2::segment::SegmentRow {
8330                                server_version: 2,
8331                                values,
8332                            }]],
8333                        };
8334                        assert!(
8335                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
8336                            "{kind}"
8337                        );
8338                    }
8339                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
8340                    let actual: String = client
8341                        .conn
8342                        .query_row(
8343                            "SELECT title FROM tasks WHERE id=?1",
8344                            [RowParam::Cell(&key)],
8345                            |row| row.get(0),
8346                        )
8347                        .unwrap();
8348                    assert_eq!(actual, expected, "{kind}, images={images}");
8349                }
8350                assert_eq!(client.overlay_rebuild_count.get(), 0);
8351            }
8352        }
8353    }
8354
8355    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
8356        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8357            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
8358            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
8359            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8360        })))});
8361        let mut client =
8362            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
8363        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();
8364        client.rebuild_overlay().expect("rebuild overlay");
8365        client
8366            .mutate(vec![Mutation::Upsert {
8367                table: "tasks".into(),
8368                values: Map::from_iter([
8369                    ("id".into(), json!("offline")),
8370                    ("project_id".into(), json!("p1")),
8371                    ("title".into(), json!("pending edit")),
8372                ]),
8373                base_version: None,
8374            }])
8375            .unwrap();
8376        let image = Connection::open_in_memory().unwrap();
8377        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8378            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
8379        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();
8380        image
8381            .execute(
8382                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
8383                [rows],
8384            )
8385            .unwrap();
8386        client.overlay_rebuild_count.set(0);
8387        let table = client.schema.table("tasks").unwrap().clone();
8388        (client, image, table)
8389    }
8390
8391    #[test]
8392    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
8393        let (mut client, image, table) = unique_import_fixture(1);
8394        image
8395            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
8396            .unwrap();
8397        client
8398            .conn
8399            .execute_batch("PRAGMA max_page_count = 1")
8400            .unwrap();
8401        let effective = [("project_id".into(), vec!["p1".into()])];
8402        assert!(client
8403            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
8404            .is_err());
8405        let failure = client.storage_failure.borrow().clone().unwrap();
8406        assert_eq!(failure.code, Some("client.storage_full"));
8407        assert_eq!(failure.sqlite_code, Some(13));
8408        assert!(!failure.message.contains("rollback"));
8409        assert!(client.conn.is_autocommit());
8410        client
8411            .conn
8412            .execute_batch("PRAGMA max_page_count = 1073741823")
8413            .unwrap();
8414        assert!(matches!(
8415            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
8416            Ok(1)
8417        ));
8418        assert!(client.conn.is_autocommit());
8419    }
8420
8421    #[test]
8422    fn authoring_storage_failures_are_structured_and_scoped() {
8423        let path = std::env::temp_dir().join(format!(
8424            "syncular-authoring-failure-{}.db",
8425            uuid::Uuid::new_v4()
8426        ));
8427        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8428            {"name":"id","type":"string","nullable":false},
8429            {"name":"project_id","type":"string","nullable":false},
8430            {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8431        let mut client = SyncClient::open_path(
8432            "authoring".into(),
8433            &schema,
8434            ClientLimits::default(),
8435            path.to_str().unwrap(),
8436        )
8437        .expect("open client");
8438        client.create_synced_tables().expect("create synced tables");
8439        // Return BUSY immediately rather than waiting out the file timeout.
8440        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8441
8442        let upsert = |id: &str| {
8443            vec![Mutation::Upsert {
8444                table: "tasks".into(),
8445                values: Map::from_iter([
8446                    ("id".into(), json!(id)),
8447                    ("project_id".into(), json!("p1")),
8448                    ("title".into(), json!("first")),
8449                ]),
8450                base_version: None,
8451            }]
8452        };
8453
8454        let locker = Connection::open(&path).expect("second connection");
8455        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8456        let busy = client.mutate(upsert("t1")).expect_err("busy write");
8457        assert_eq!(busy.code, "client.storage_busy");
8458        assert!(busy.retryable);
8459        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8460        assert!(client.pending_commit_ids().is_empty());
8461        assert_eq!(client.local_revision(), 0);
8462
8463        // A stale classified failure must not classify a later unrelated one.
8464        let invalid = client.mutate(Vec::new()).expect_err("empty commit");
8465        assert_eq!(invalid.code, "sync.invalid_request");
8466        assert_eq!(invalid.message, "the authoring request is invalid");
8467        assert!(
8468            invalid.details.as_ref().unwrap()["legacyCause"]
8469                .as_str()
8470                .unwrap()
8471                .contains("at least one operation"),
8472            "{invalid:?}"
8473        );
8474
8475        locker.execute_batch("ROLLBACK").unwrap();
8476        let id = client.mutate(upsert("t1")).expect("retry after busy");
8477        assert_eq!(client.pending_commit_ids(), vec![id]);
8478        assert_eq!(client.local_revision(), 1);
8479
8480        // A later storage failure reports its own code and details.
8481        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8482        let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
8483        assert_eq!(busy_again.code, "client.storage_busy");
8484        assert!(busy_again.retryable);
8485        locker.execute_batch("ROLLBACK").unwrap();
8486
8487        // Non-retryable classified failures keep their sqlite evidence too.
8488        let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
8489            rusqlite::ffi::Error::new(13),
8490            Some("database or disk is full".to_owned()),
8491        )));
8492        assert_eq!(full.code, "client.storage_full");
8493        assert!(!full.retryable);
8494        assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
8495
8496        drop(locker);
8497        drop(client);
8498        let _ = std::fs::remove_file(&path);
8499    }
8500
8501    #[test]
8502    fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
8503        let path = std::env::temp_dir().join(format!(
8504            "syncular-caller-values-{}.db",
8505            uuid::Uuid::new_v4()
8506        ));
8507        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8508            {"name":"id","type":"string","nullable":false},
8509            {"name":"project_id","type":"string","nullable":false},
8510            {"name":"title","type":"string","nullable":false},
8511            {"name":"priority","type":"integer","nullable":false},
8512            {"name":"payload","type":"bytes","nullable":false},
8513            {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8514        let mut client = SyncClient::open_path(
8515            "caller-values".into(),
8516            &schema,
8517            ClientLimits::default(),
8518            path.to_str().unwrap(),
8519        )
8520        .expect("open client");
8521        client.create_synced_tables().expect("create synced tables");
8522
8523        let values = |id: &str| {
8524            Map::from_iter([
8525                ("id".to_owned(), json!(id)),
8526                ("project_id".to_owned(), json!("p1")),
8527                ("title".to_owned(), json!("first")),
8528                ("priority".to_owned(), json!(1)),
8529                ("payload".to_owned(), json!({"$bytes": "0a"})),
8530            ])
8531        };
8532        let upsert = |values: Map<String, Value>| {
8533            vec![Mutation::Upsert {
8534                table: "tasks".into(),
8535                values,
8536                base_version: None,
8537            }]
8538        };
8539        let seed = client.mutate(upsert(values("t1"))).expect("seed row");
8540        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
8541
8542        // §6.1/§7.1: every caller value the authoring seam rejects reports the
8543        // same stable identity instead of the internal-failure default, keeps
8544        // the dynamic cause in `details.legacyCause`, and leaves the durable
8545        // outbox, the local revision and the projection untouched.
8546        for (label, override_value, cause) in [
8547            ("unknown column", ("nope", json!(1)), "unknown column"),
8548            (
8549                "internal sync column",
8550                ("_sync_version", json!(1)),
8551                "internal sync column",
8552            ),
8553            ("wrong value type", ("title", json!(7)), "expected a string"),
8554            (
8555                "malformed bytes envelope",
8556                ("payload", json!({"$bytes": "zz"})),
8557                "expected exactly",
8558            ),
8559            (
8560                "odd-length bytes envelope",
8561                ("payload", json!({"$bytes": "abc"})),
8562                "expected exactly",
8563            ),
8564            (
8565                "plus-sign bytes envelope",
8566                ("payload", json!({"$bytes": "+a"})),
8567                "expected exactly",
8568            ),
8569            (
8570                "bytes envelope with a second key",
8571                ("payload", json!({"$bytes": "0a", "extra": 1})),
8572                "expected exactly",
8573            ),
8574            (
8575                "no bytes envelope",
8576                ("payload", json!({"nested": true})),
8577                "expected exactly",
8578            ),
8579        ] {
8580            let mut full = values("t2");
8581            full.insert(override_value.0.to_owned(), override_value.1);
8582            let error = client.mutate(upsert(full)).expect_err(label);
8583            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8584            assert_eq!(error.message, "the authoring request is invalid", "{label}");
8585            assert!(!error.retryable, "{label}");
8586            let legacy = error.details.as_ref().unwrap()["legacyCause"]
8587                .as_str()
8588                .unwrap();
8589            assert!(
8590                legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
8591                "{label}: {legacy}"
8592            );
8593            assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
8594            assert_eq!(client.local_revision(), 1, "{label}");
8595            assert_eq!(
8596                client.query("SELECT * FROM tasks", &[]).unwrap(),
8597                rows,
8598                "{label}"
8599            );
8600        }
8601
8602        // A full-row upsert presents every column, so a missing required one is
8603        // the caller's failure too.
8604        let mut missing = values("t3");
8605        missing.remove("payload");
8606        let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
8607        assert_eq!(missing_error.code, "sync.invalid_request");
8608        assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
8609            .as_str()
8610            .unwrap()
8611            .contains("is not nullable"));
8612
8613        // A primary key the wire cannot render is rejected before any op is
8614        // recorded.
8615        let mut bad_row_id = values("t4");
8616        bad_row_id.insert("id".to_owned(), json!({"nested": true}));
8617        let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
8618        assert_eq!(row_id_error.code, "sync.invalid_request");
8619
8620        // The sparse route classifies the same value failures.
8621        for (label, partial, cause) in [
8622            (
8623                "patch wrong value type",
8624                ("priority", json!("high")),
8625                "expected an integer",
8626            ),
8627            ("patch unknown column", ("nope", json!(1)), "unknown column"),
8628        ] {
8629            let error = client
8630                .patch(
8631                    "tasks",
8632                    "t1",
8633                    Map::from_iter([(partial.0.to_owned(), partial.1)]),
8634                    None,
8635                )
8636                .expect_err(label);
8637            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8638            assert_eq!(error.message, "the authoring request is invalid", "{label}");
8639            assert!(
8640                error.details.as_ref().unwrap()["legacyCause"]
8641                    .as_str()
8642                    .unwrap()
8643                    .contains(cause),
8644                "{label}"
8645            );
8646        }
8647        assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
8648        assert_eq!(client.local_revision(), 1);
8649        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
8650
8651        // Nullable explicit null, an empty envelope, and uppercase digits stay
8652        // accepted; the stored payload is the decoded byte sequence.
8653        for (label, payload) in [
8654            ("explicit null", json!(null)),
8655            ("empty envelope", json!({"$bytes": ""})),
8656            ("uppercase envelope", json!({"$bytes": "0AFF"})),
8657        ] {
8658            let mut accepted = values("t-ok");
8659            accepted.insert("extra".to_owned(), payload);
8660            let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
8661                panic!("{label} must be accepted: {error:?}");
8662            });
8663            assert!(!encoded.is_empty(), "{label}");
8664        }
8665
8666        // Nothing is poisoned: the next valid authoring call succeeds.
8667        let next = client
8668            .mutate(upsert(values("t5")))
8669            .expect("valid after failures");
8670        assert_ne!(next, seed);
8671
8672        drop(client);
8673        let _ = std::fs::remove_file(&path);
8674    }
8675
8676    #[test]
8677    fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
8678        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8679            {"name":"id","type":"string","nullable":false},
8680            {"name":"project_id","type":"string","nullable":false},
8681            {"name":"payload","type":"bytes","nullable":true}],
8682            "scopes":[{"pattern":"project:{project_id}"}]}]});
8683        let open = |path: &std::path::Path| {
8684            SyncClient::open_path(
8685                "legacy-values".into(),
8686                &schema,
8687                ClientLimits::default(),
8688                path.to_str().unwrap(),
8689            )
8690            .expect("open client")
8691        };
8692        let row = |id: &str| {
8693            Map::from_iter([
8694                ("id".to_owned(), json!(id)),
8695                ("project_id".to_owned(), json!("p1")),
8696                ("payload".to_owned(), json!({"$bytes": "0a"})),
8697            ])
8698        };
8699        let upsert = |values| {
8700            vec![Mutation::Upsert {
8701                table: "tasks".into(),
8702                values,
8703                base_version: None,
8704            }]
8705        };
8706        for (label, stored_cell, representable) in [
8707            ("plus-sign envelope", json!({"$bytes": "+a"}), false),
8708            ("non-hex envelope", json!({"$bytes": "zz"}), false),
8709            ("odd-length envelope", json!({"$bytes": "abc"}), false),
8710            ("no envelope", json!({"nested": true}), false),
8711            // A wrong declared type and a stored null for a non-nullable column
8712            // are journalable as stored, so their rejection keeps the operation
8713            // envelope.
8714            ("wrong declared type", json!("high"), true),
8715            ("stored null for a non-nullable column", json!(null), true),
8716        ] {
8717            let path = std::env::temp_dir().join(format!(
8718                "syncular-legacy-values-{}-{}.db",
8719                label.replace(' ', "-"),
8720                uuid::Uuid::new_v4()
8721            ));
8722            let mut client = open(&path);
8723            client.create_synced_tables().expect("create synced tables");
8724            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8725            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8726            drop(client);
8727
8728            // The stored intent an earlier version accepted.
8729            let connection = Connection::open(&path).expect("raw connection");
8730            let (column, cell) = if stored_cell.is_null() {
8731                ("project_id", stored_cell.clone())
8732            } else {
8733                ("payload", stored_cell.clone())
8734            };
8735            let mut planted_values = json!({
8736                "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
8737            });
8738            planted_values[column] = cell;
8739            let planted = json!([{
8740                "op": "upsert",
8741                "table": "tasks",
8742                "rowId": "t1",
8743                "baseVersion": null,
8744                "values": planted_values
8745            }]);
8746            connection
8747                .execute(
8748                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8749                    rusqlite::params![planted.to_string(), stale],
8750                )
8751                .expect("plant legacy intent");
8752            drop(connection);
8753
8754            // Reopen: the startup reconciliation recovers the commit before any
8755            // replay, so the observable phase matches the browser core.
8756            let reopened = open(&path);
8757            assert_eq!(
8758                reopened.pending_commit_ids(),
8759                vec![successor.clone()],
8760                "{label}"
8761            );
8762            let rejection = reopened
8763                .rejections()
8764                .iter()
8765                .find(|rejection| rejection.client_commit_id == stale)
8766                .unwrap_or_else(|| panic!("{label}: rejection"));
8767            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8768            assert_eq!(
8769                rejection.message, "the persisted commit carries values the current codec refuses",
8770                "{label}"
8771            );
8772            assert_eq!(rejection.operation.is_some(), representable, "{label}");
8773            assert_eq!(
8774                rejection
8775                    .details
8776                    .as_ref()
8777                    .and_then(|details| details.reason.as_deref()),
8778                Some(INVALID_STORED_VALUES_REASON),
8779                "{label}"
8780            );
8781            // The rejection stays readable after a further reopen.
8782            let journal = reopened
8783                .commit_outcome(&stale)
8784                .expect("read outcome")
8785                .expect("journal entry");
8786            assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8787            drop(reopened);
8788            let mut reopened = open(&path);
8789            let reread = reopened
8790                .commit_outcome(&stale)
8791                .expect("read outcome")
8792                .expect("journal entry");
8793            assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8794
8795            // The legacy row's optimistic projection is gone, the successor
8796            // survives and still encodes, and the round path keeps the queue.
8797            assert_eq!(
8798                reopened
8799                    .query("SELECT id FROM tasks ORDER BY id", &[])
8800                    .unwrap(),
8801                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8802                "{label}"
8803            );
8804            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8805            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8806            assert!(!reopened
8807                .drop_unencodable_outbox(false, None)
8808                .expect("idempotent prepass"));
8809            let mut transport = CountingRealtimeTransport::default();
8810            let outcome = reopened.sync(&mut transport);
8811            assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8812            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8813            let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8814            assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8815            assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8816            drop(reopened);
8817            let _ = std::fs::remove_file(&path);
8818        }
8819    }
8820
8821    #[test]
8822    fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8823        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8824            {"name":"id","type":"string","nullable":false},
8825            {"name":"project_id","type":"string","nullable":false},
8826            {"name":"payload","type":"bytes","nullable":true}],
8827            "scopes":[{"pattern":"project:{project_id}"}]}]});
8828        for (label, column, planted) in [
8829            ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8830            (
8831                "stored null for a non-nullable column",
8832                "project_id",
8833                json!(null),
8834            ),
8835            ("wrong declared type", "payload", json!(7)),
8836        ] {
8837            let path = std::env::temp_dir().join(format!(
8838                "syncular-shared-row-{}-{}.db",
8839                label.replace(' ', "-"),
8840                uuid::Uuid::new_v4()
8841            ));
8842            let open = |path: &std::path::Path| {
8843                SyncClient::open_path(
8844                    "shared-row".into(),
8845                    &schema,
8846                    ClientLimits::default(),
8847                    path.to_str().unwrap(),
8848                )
8849                .expect("open client")
8850            };
8851            let row = |id: &str| {
8852                Map::from_iter([
8853                    ("id".to_owned(), json!(id)),
8854                    ("project_id".to_owned(), json!("p1")),
8855                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8856                ])
8857            };
8858            let upsert = |values| {
8859                vec![Mutation::Upsert {
8860                    table: "tasks".into(),
8861                    values,
8862                    base_version: None,
8863                }]
8864            };
8865            let mut client = open(&path);
8866            client.create_synced_tables().expect("create synced tables");
8867            let older = client.mutate(upsert(row("t1"))).expect("older commit");
8868            let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8869            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8870            drop(client);
8871
8872            let connection = Connection::open(&path).expect("raw connection");
8873            for commit_id in [&older, &newer] {
8874                let mut values = row("t1");
8875                values.insert(column.to_owned(), planted.clone());
8876                let planted_ops = json!([{
8877                    "op": "upsert",
8878                    "table": "tasks",
8879                    "rowId": "t1",
8880                    "baseVersion": null,
8881                    "values": values
8882                }]);
8883                connection
8884                    .execute(
8885                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8886                        rusqlite::params![planted_ops.to_string(), commit_id],
8887                    )
8888                    .expect("plant legacy intent");
8889            }
8890            drop(connection);
8891
8892            // Startup recovery drops both, FIFO, and publishes one batch.
8893            let mut reopened = open(&path);
8894            assert_eq!(
8895                reopened.pending_commit_ids(),
8896                vec![successor.clone()],
8897                "{label}"
8898            );
8899            assert_eq!(
8900                reopened
8901                    .rejections()
8902                    .iter()
8903                    .map(|rejection| rejection.client_commit_id.clone())
8904                    .collect::<Vec<_>>(),
8905                vec![older.clone(), newer.clone()],
8906                "{label}"
8907            );
8908            assert!(
8909                reopened
8910                    .rejections()
8911                    .iter()
8912                    .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8913                        && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8914                            == Some(INVALID_STORED_VALUES_REASON)),
8915                "{label}"
8916            );
8917            for commit_id in [&older, &newer] {
8918                assert!(
8919                    matches!(
8920                        reopened
8921                            .commit_outcome(commit_id)
8922                            .expect("outcome")
8923                            .map(|o| o.status),
8924                        Some(CommitOutcomeStatus::Rejected)
8925                    ),
8926                    "{label}"
8927                );
8928            }
8929            assert_eq!(
8930                reopened
8931                    .query("SELECT id FROM tasks ORDER BY id", &[])
8932                    .unwrap(),
8933                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8934                "{label}"
8935            );
8936            let revision = reopened.local_revision();
8937            let batches = reopened.drain_change_batches();
8938            assert!(
8939                batches.iter().any(|batch| batch.rejections_changed
8940                    && batch.outcomes_changed
8941                    && batch.tables.iter().any(|change| change.table == "tasks")
8942                    && batch.revision == revision.to_string()),
8943                "{label}: {batches:?}"
8944            );
8945            drop(reopened);
8946
8947            // An ordinary reopen with nothing to recover publishes nothing and
8948            // leaves the revision alone.
8949            let mut stable = open(&path);
8950            assert_eq!(stable.local_revision(), revision, "{label}");
8951            assert!(stable.drain_change_batches().is_empty(), "{label}");
8952            assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8953            drop(stable);
8954            let _ = std::fs::remove_file(&path);
8955        }
8956    }
8957
8958    #[test]
8959    fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8960        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8961            {"name":"id","type":"string","nullable":false},
8962            {"name":"project_id","type":"string","nullable":false},
8963            {"name":"payload","type":"bytes","nullable":true}],
8964            "scopes":[{"pattern":"project:{project_id}"}]}]});
8965        for (label, second_op) in [
8966            (
8967                "delete carrying values",
8968                json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8969            ),
8970            (
8971                "baseVersion beyond the safe bound",
8972                json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8973                    "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8974                    "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8975            ),
8976        ] {
8977            let path = std::env::temp_dir().join(format!(
8978                "syncular-journal-shape-{}-{}.db",
8979                label.replace(' ', "-"),
8980                uuid::Uuid::new_v4()
8981            ));
8982            let open = |path: &std::path::Path| {
8983                SyncClient::open_path(
8984                    "journal-shape".into(),
8985                    &schema,
8986                    ClientLimits::default(),
8987                    path.to_str().unwrap(),
8988                )
8989                .expect("open client")
8990            };
8991            let values = |id: &str| {
8992                Map::from_iter([
8993                    ("id".to_owned(), json!(id)),
8994                    ("project_id".to_owned(), json!("p1")),
8995                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8996                ])
8997            };
8998            let upsert = |values| {
8999                vec![Mutation::Upsert {
9000                    table: "tasks".into(),
9001                    values,
9002                    base_version: None,
9003                }]
9004            };
9005            let mut client = open(&path);
9006            client.create_synced_tables().expect("create synced tables");
9007            let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
9008            drop(client);
9009
9010            let connection = Connection::open(&path).expect("raw connection");
9011            // The first operation triggers the drop with canonical cells (a
9012            // stored null for a non-nullable column); the second one is the
9013            // operation shape the journal reader refuses.
9014            let planted = json!([
9015                {
9016                    "op": "upsert",
9017                    "table": "tasks",
9018                    "rowId": "t1",
9019                    "baseVersion": null,
9020                    "values": {"id": "t1", "project_id": null,
9021                        "payload": {"$bytes": "0a"}}
9022                },
9023                second_op
9024            ]);
9025            connection
9026                .execute(
9027                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9028                    rusqlite::params![planted.to_string(), stale],
9029                )
9030                .expect("plant legacy intent");
9031            drop(connection);
9032
9033            let reopened = open(&path);
9034            let journal = reopened
9035                .commit_outcome(&stale)
9036                .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
9037                .unwrap_or_else(|| panic!("{label}: journal entry"));
9038            assert!(
9039                matches!(journal.status, CommitOutcomeStatus::Rejected),
9040                "{label}"
9041            );
9042            assert!(journal.operations.is_none(), "{label}");
9043            assert!(
9044                reopened
9045                    .commit_outcomes(CommitOutcomeQuery::default())
9046                    .is_ok(),
9047                "{label}: outcome list query"
9048            );
9049            assert!(reopened.pending_commit_ids().is_empty(), "{label}");
9050            drop(reopened);
9051            let _ = std::fs::remove_file(&path);
9052        }
9053    }
9054
9055    #[test]
9056    fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
9057        let schema = |version: i32, extra: bool| {
9058            let mut columns = vec![
9059                json!({"name":"id","type":"string","nullable":false}),
9060                json!({"name":"project_id","type":"string","nullable":false}),
9061                json!({"name":"payload","type":"bytes","nullable":true}),
9062            ];
9063            if extra {
9064                columns.push(json!({"name":"later","type":"string","nullable":true}));
9065            }
9066            json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
9067                "scopes":[{"pattern":"project:{project_id}"}]}]})
9068        };
9069        for (label, bump) in [
9070            ("same-version recreation", false),
9071            ("version-bump reset", true),
9072            ("version-only bump reset", true),
9073        ] {
9074            let path = std::env::temp_dir().join(format!(
9075                "syncular-legacy-reset-{}-{}.db",
9076                label.replace(' ', "-"),
9077                uuid::Uuid::new_v4()
9078            ));
9079            let v1 = schema(1, false);
9080            let open = |schema: &Value, path: &std::path::Path| {
9081                SyncClient::open_path(
9082                    "legacy-reset".into(),
9083                    schema,
9084                    ClientLimits::default(),
9085                    path.to_str().unwrap(),
9086                )
9087                .expect("open client")
9088            };
9089            let row = |id: &str| {
9090                Map::from_iter([
9091                    ("id".to_owned(), json!(id)),
9092                    ("project_id".to_owned(), json!("p1")),
9093                    ("payload".to_owned(), json!({"$bytes": "0a"})),
9094                ])
9095            };
9096            let upsert = |values| {
9097                vec![Mutation::Upsert {
9098                    table: "tasks".into(),
9099                    values,
9100                    base_version: None,
9101                }]
9102            };
9103            let mut client = open(&v1, &path);
9104            client.create_synced_tables().expect("create synced tables");
9105            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
9106            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
9107            drop(client);
9108
9109            let connection = Connection::open(&path).expect("raw connection");
9110            let planted = json!([{
9111                "op": "upsert",
9112                "table": "tasks",
9113                "rowId": "t1",
9114                "baseVersion": null,
9115                "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
9116            }]);
9117            connection
9118                .execute(
9119                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9120                    rusqlite::params![planted.to_string(), stale],
9121                )
9122                .expect("plant legacy intent");
9123            drop(connection);
9124
9125            let target = if bump {
9126                schema(2, label == "version-bump reset")
9127            } else {
9128                v1.clone()
9129            };
9130            let reopened = open(&target, &path);
9131            let rejection = reopened
9132                .rejections()
9133                .iter()
9134                .find(|rejection| rejection.client_commit_id == stale)
9135                .unwrap_or_else(|| panic!("{label}: rejection"));
9136            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9137            assert_eq!(
9138                rejection
9139                    .details
9140                    .as_ref()
9141                    .and_then(|details| details.reason.as_deref()),
9142                Some(INVALID_STORED_VALUES_REASON),
9143                "{label}"
9144            );
9145            assert!(rejection.operation.is_some(), "{label}");
9146            assert_eq!(
9147                reopened.pending_commit_ids(),
9148                vec![successor.clone()],
9149                "{label}"
9150            );
9151            // The successor survives and still encodes. A bump that adds a
9152            // column leaves the older full-row upsert partial over the empty
9153            // base, so its visible row appears when the server answers it
9154            // (§7.1); the same-version recreation replays it locally.
9155            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
9156            if label == "version-bump reset" {
9157                assert_eq!(
9158                    reopened
9159                        .query("SELECT id FROM tasks ORDER BY id", &[])
9160                        .unwrap(),
9161                    Vec::<Map<String, Value>>::new(),
9162                    "{label}"
9163                );
9164                // The guarded bump reset owns the publication, so its change
9165                // batch carries the recovery's rejection, outcome, and table
9166                // domains at the revision it advanced to.
9167                let mut next = reopened;
9168                let revision = next.local_revision();
9169                let batches = next.drain_change_batches();
9170                assert!(
9171                    batches.iter().any(|batch| batch.rejections_changed
9172                        && batch.outcomes_changed
9173                        && batch.tables.iter().any(|change| change.table == "tasks")
9174                        && batch.revision == revision.to_string()),
9175                    "{label}: {batches:?}"
9176                );
9177                next.mutate(upsert(Map::from_iter([
9178                    ("id".to_owned(), json!("t3")),
9179                    ("project_id".to_owned(), json!("p1")),
9180                    ("payload".to_owned(), json!({"$bytes": "0b"})),
9181                    ("later".to_owned(), json!("v2")),
9182                ])))
9183                .expect("v2 authoring after the reset");
9184                assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
9185                assert_eq!(next.pending_payloads().len(), 2, "{label}");
9186                drop(next);
9187            } else {
9188                assert_eq!(
9189                    reopened
9190                        .query("SELECT id FROM tasks ORDER BY id", &[])
9191                        .unwrap(),
9192                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9193                    "{label}"
9194                );
9195                drop(reopened);
9196            }
9197
9198            // Queue and corrupt a new commit while the client stays open so only
9199            // recreation can recover it.
9200            if !bump {
9201                let mut recreated = open(&v1, &path);
9202                let fresh = recreated
9203                    .mutate(upsert(row("t3")))
9204                    .expect("fresh commit while open");
9205                recreated.drain_change_batches();
9206                let planted_fresh = json!([{
9207                    "op": "upsert",
9208                    "table": "tasks",
9209                    "rowId": "t3",
9210                    "baseVersion": null,
9211                    "values": {"id": "t3", "project_id": "p1",
9212                        "payload": {"$bytes": "zz"}}
9213                }]);
9214                let connection = Connection::open(&path).expect("raw connection");
9215                let affected = connection
9216                    .execute(
9217                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9218                        rusqlite::params![planted_fresh.to_string(), fresh],
9219                    )
9220                    .expect("plant fresh legacy intent");
9221                assert_eq!(
9222                    affected, 1,
9223                    "{label}: the in-place plant must hit the queued commit"
9224                );
9225                drop(connection);
9226                let revision_before = recreated.local_revision();
9227                recreated
9228                    .recreate_with_schema(&v1)
9229                    .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
9230                assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
9231                let fresh_rejection = recreated
9232                    .rejections()
9233                    .iter()
9234                    .find(|rejection| rejection.client_commit_id == fresh)
9235                    .expect("the in-place recovery rejection");
9236                assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9237                assert_eq!(
9238                    fresh_rejection
9239                        .details
9240                        .as_ref()
9241                        .and_then(|details| details.reason.as_deref()),
9242                    Some(INVALID_STORED_VALUES_REASON),
9243                    "{label}"
9244                );
9245                assert!(
9246                    matches!(
9247                        recreated
9248                            .commit_outcome(&fresh)
9249                            .expect("outcome read")
9250                            .map(|outcome| outcome.status),
9251                        Some(CommitOutcomeStatus::Rejected)
9252                    ),
9253                    "{label}"
9254                );
9255                assert!(recreated.local_revision() > revision_before, "{label}");
9256                let revision = recreated.local_revision();
9257                let batches = recreated.drain_change_batches();
9258                assert_eq!(batches.len(), 1, "{label}: one recovery batch");
9259                assert!(
9260                    batches.iter().any(|batch| batch.rejections_changed
9261                        && batch.outcomes_changed
9262                        && batch.tables.iter().any(|change| change.table == "tasks")
9263                        && batch.revision == revision.to_string()),
9264                    "{label}: {batches:?}"
9265                );
9266                assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
9267                assert_eq!(
9268                    recreated
9269                        .query("SELECT id FROM tasks ORDER BY id", &[])
9270                        .unwrap(),
9271                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9272                    "{label}: the recovered commit leaves no projection"
9273                );
9274                drop(recreated);
9275            }
9276            let _ = std::fs::remove_file(&path);
9277        }
9278    }
9279
9280    #[test]
9281    fn legacy_values_recover_over_a_synced_authoritative_row() {
9282        let path = std::env::temp_dir().join(format!(
9283            "syncular-authoritative-row-{}.db",
9284            uuid::Uuid::new_v4()
9285        ));
9286        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9287            {"name":"id","type":"string","nullable":false},
9288            {"name":"project_id","type":"string","nullable":false},
9289            {"name":"title","type":"string","nullable":false},
9290            {"name":"payload","type":"bytes","nullable":true}],
9291            "scopes":[{"pattern":"project:{project_id}"}]}]});
9292        let open = |path: &std::path::Path| {
9293            SyncClient::open_path(
9294                "authoritative-row".into(),
9295                &schema,
9296                ClientLimits::default(),
9297                path.to_str().unwrap(),
9298            )
9299            .expect("open client")
9300        };
9301        let row = |title: &str| {
9302            Map::from_iter([
9303                ("id".to_owned(), json!("t1")),
9304                ("project_id".to_owned(), json!("p1")),
9305                ("title".to_owned(), json!(title)),
9306                ("payload".to_owned(), json!({"$bytes": "0a"})),
9307            ])
9308        };
9309        let upsert = |values| {
9310            vec![Mutation::Upsert {
9311                table: "tasks".into(),
9312                values,
9313                base_version: None,
9314            }]
9315        };
9316        let mut client = open(&path);
9317        client.create_synced_tables().expect("create synced tables");
9318        // A server-confirmed row: base and visible carry the authoritative
9319        // values at version 1.
9320        client
9321            .conn
9322            .execute_batch(
9323                "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
9324                 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
9325            )
9326            .expect("seed authoritative row");
9327        let older = client.mutate(upsert(row("older"))).expect("older");
9328        let newer = client.mutate(upsert(row("newer"))).expect("newer");
9329        let successor = client.mutate(upsert(row("successor"))).expect("successor");
9330        drop(client);
9331
9332        let connection = Connection::open(&path).expect("raw connection");
9333        for commit_id in [&older, &newer] {
9334            let planted = json!([{
9335                "op": "upsert",
9336                "table": "tasks",
9337                "rowId": "t1",
9338                "baseVersion": null,
9339                "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9340                    "payload": {"$bytes": "zz"}}
9341            }]);
9342            connection
9343                .execute(
9344                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9345                    rusqlite::params![planted.to_string(), commit_id],
9346                )
9347                .expect("plant legacy intent");
9348        }
9349        drop(connection);
9350
9351        let reopened = open(&path);
9352        assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
9353        assert_eq!(
9354            reopened
9355                .rejections()
9356                .iter()
9357                .map(|rejection| rejection.client_commit_id.clone())
9358                .collect::<Vec<_>>(),
9359            vec![older.clone(), newer.clone()]
9360        );
9361        // The pending successor overlays the restored authoritative row, which
9362        // keeps its server version.
9363        assert_eq!(
9364            reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9365            Ok(vec![Map::from_iter([
9366                ("id".to_owned(), json!("t1")),
9367                ("title".to_owned(), json!("successor")),
9368                ("_syncular_version".to_owned(), json!(1)),
9369            ])])
9370        );
9371
9372        // A later rejection of the surviving successor restores the
9373        // authoritative values and version exactly.
9374        drop(reopened);
9375        let connection = Connection::open(&path).expect("raw connection");
9376        connection
9377            .execute(
9378                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9379                rusqlite::params![
9380                    json!([{
9381                        "op": "upsert",
9382                        "table": "tasks",
9383                        "rowId": "t1",
9384                        "baseVersion": null,
9385                        "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9386                            "payload": {"$bytes": "zz"}}
9387                    }])
9388                    .to_string(),
9389                    successor
9390                ],
9391            )
9392            .expect("plant successor intent");
9393        drop(connection);
9394        let late = open(&path);
9395        assert!(late.pending_commit_ids().is_empty());
9396        assert_eq!(
9397            late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9398            Ok(vec![Map::from_iter([
9399                ("id".to_owned(), json!("t1")),
9400                ("title".to_owned(), json!("authoritative")),
9401                ("_syncular_version".to_owned(), json!(1)),
9402            ])])
9403        );
9404        drop(late);
9405        let _ = std::fs::remove_file(&path);
9406    }
9407
9408    #[test]
9409    fn a_failed_value_recovery_commit_restores_every_side_effect() {
9410        let path = std::env::temp_dir().join(format!(
9411            "syncular-value-recovery-commit-{}.db",
9412            uuid::Uuid::new_v4()
9413        ));
9414        let path_str = path.to_str().expect("path");
9415        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9416            {"name":"id","type":"string","nullable":false},
9417            {"name":"project_id","type":"string","nullable":false},
9418            {"name":"payload","type":"bytes","nullable":true}],
9419            "scopes":[{"pattern":"project:{project_id}"}]}]});
9420        // A rollback-journal replica, so a second read connection can hold
9421        // SHARED across the recovery transaction's COMMIT.
9422        let conn = Connection::open(path_str).expect("replica connection");
9423        let mut client = SyncClient::with_connection(
9424            "value-recovery-commit".into(),
9425            &schema,
9426            ClientLimits::default(),
9427            conn,
9428        )
9429        .expect("replica");
9430        assert_eq!(
9431            client
9432                .conn
9433                .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
9434                .expect("journal mode"),
9435            "delete"
9436        );
9437        client.create_synced_tables().expect("create synced tables");
9438        let stale = client
9439            .mutate(vec![Mutation::Upsert {
9440                table: "tasks".into(),
9441                values: Map::from_iter([
9442                    ("id".to_owned(), json!("t1")),
9443                    ("project_id".to_owned(), json!("p1")),
9444                    ("payload".to_owned(), json!({"$bytes": "0a"})),
9445                ]),
9446                base_version: None,
9447            }])
9448            .expect("queued write");
9449        client.drain_change_batches();
9450        let affected = client
9451            .conn
9452            .execute(
9453                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9454                rusqlite::params![
9455                    json!([{
9456                        "op": "upsert",
9457                        "table": "tasks",
9458                        "rowId": "t1",
9459                        "baseVersion": null,
9460                        "values": {"id": "t1", "project_id": "p1",
9461                            "payload": {"$bytes": "zz"}}
9462                    }])
9463                    .to_string(),
9464                    stale
9465                ],
9466            )
9467            .expect("plant legacy intent");
9468        assert_eq!(affected, 1, "the planted row must exist");
9469        let revision = client.local_revision();
9470        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
9471        let prior_outbox = client.pending_commit_ids();
9472        let prior_rejections = client.rejections().len();
9473        let prior_changes = client.change_queue.len();
9474        let prior_intents = client.sync_intent_queue.len();
9475        let prior_failed = client.failed_commits.len();
9476        // The reader holds SHARED for the whole attempt: the recovery runs to
9477        // its publication and the COMMIT fails.
9478        let reader = Connection::open(path_str).expect("reader");
9479        reader
9480            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9481            .expect("reader snapshot");
9482        client
9483            .conn
9484            .busy_timeout(std::time::Duration::ZERO)
9485            .expect("busy timeout");
9486        let error = client
9487            .recreate_with_schema(&schema)
9488            .expect_err("a blocked COMMIT must refuse the recovery");
9489        assert!(error.starts_with("client.storage_busy:"), "{error}");
9490        assert!(client.conn.is_autocommit(), "the transaction is released");
9491        assert_eq!(client.local_revision(), revision);
9492        assert_eq!(client.pending_commit_ids(), prior_outbox);
9493        assert_eq!(client.rejections().len(), prior_rejections);
9494        assert_eq!(client.change_queue.len(), prior_changes);
9495        assert_eq!(client.sync_intent_queue.len(), prior_intents);
9496        assert_eq!(client.failed_commits.len(), prior_failed);
9497        assert_eq!(
9498            client.query("SELECT * FROM tasks", &[]).expect("rows"),
9499            rows
9500        );
9501        assert!(
9502            client
9503                .commit_outcome(&stale)
9504                .expect("outcome read")
9505                .is_none(),
9506            "the rolled-back recovery kept no outcome"
9507        );
9508        // The retry runs the same publication branch to completion.
9509        reader.execute_batch("ROLLBACK").expect("release reader");
9510        client
9511            .recreate_with_schema(&schema)
9512            .expect("retry after the blocked commit");
9513        assert!(client.pending_commit_ids().is_empty());
9514        assert_eq!(client.rejections().len(), prior_rejections + 1);
9515        assert!(client.local_revision() > revision);
9516        let batches = client.drain_change_batches();
9517        assert!(
9518            batches.iter().any(|batch| batch.rejections_changed
9519                && batch.outcomes_changed
9520                && batch.tables.iter().any(|change| change.table == "tasks")),
9521            "{batches:?}"
9522        );
9523        drop(client);
9524        let _ = std::fs::remove_file(&path);
9525    }
9526
9527    #[cfg(feature = "e2ee")]
9528    #[test]
9529    fn a_failed_recovery_restores_retained_failed_commits() {
9530        let path = std::env::temp_dir().join(format!(
9531            "syncular-value-recovery-retention-{}.db",
9532            uuid::Uuid::new_v4()
9533        ));
9534        let path_str = path.to_str().expect("path");
9535        let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
9536            {"name":"id","type":"string","nullable":false},
9537            {"name":"project_id","type":"string","nullable":false},
9538            {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
9539            "scopes":[{"pattern":"project:{project_id}"}]}]});
9540        let conn = Connection::open(path_str).expect("replica connection");
9541        let mut client = SyncClient::with_connection(
9542            "value-recovery-retention".into(),
9543            &schema,
9544            ClientLimits::default(),
9545            conn,
9546        )
9547        .expect("replica");
9548        client.create_synced_tables().expect("create synced tables");
9549        client.set_retain_failed_commits(true).expect("retention");
9550        // No keys configured: the §5.11 seal fails, so retention is exercised.
9551        client.set_encryption(crate::values::EncryptionConfig::default());
9552        client
9553            .mutate(vec![Mutation::Upsert {
9554                table: "secrets".into(),
9555                values: Map::from_iter([
9556                    ("id".to_owned(), json!("s1")),
9557                    ("project_id".to_owned(), json!("p1")),
9558                    ("note".to_owned(), json!("secret")),
9559                ]),
9560                base_version: None,
9561            }])
9562            .expect("queued write");
9563        client.drain_change_batches();
9564        let revision = client.local_revision();
9565        let prior_outbox = client.pending_commit_ids();
9566        let prior_rejections = client.rejections().len();
9567        let prior_failed = client.failed_commits.len();
9568        let reader = Connection::open(path_str).expect("reader");
9569        reader
9570            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9571            .expect("reader snapshot");
9572        client
9573            .conn
9574            .busy_timeout(std::time::Duration::ZERO)
9575            .expect("busy timeout");
9576        let error = client
9577            .drop_unencodable_outbox(true, None)
9578            .expect_err("a blocked COMMIT must refuse the recovery");
9579        assert!(error.starts_with("client.storage_busy:"), "{error}");
9580        assert_eq!(client.failed_commits.len(), prior_failed);
9581        assert_eq!(client.pending_commit_ids(), prior_outbox);
9582        assert_eq!(client.rejections().len(), prior_rejections);
9583        assert_eq!(client.local_revision(), revision);
9584        // The retry retains the failed commit and drops it from the outbox.
9585        reader.execute_batch("ROLLBACK").expect("release reader");
9586        client
9587            .drop_unencodable_outbox(true, None)
9588            .expect("retry after the blocked commit");
9589        assert_eq!(client.failed_commits.len(), prior_failed + 1);
9590        assert!(client.pending_commit_ids().is_empty());
9591        assert_eq!(client.rejections().len(), prior_rejections + 1);
9592        drop(client);
9593        let _ = std::fs::remove_file(&path);
9594    }
9595
9596    #[test]
9597    fn client_error_maps_legacy_codes_and_static_messages() {
9598        let code_only = ClientError::from("sync.unknown_table".to_owned());
9599        assert_eq!(code_only.code, "sync.unknown_table");
9600        assert_eq!(code_only.message, "the commit targets an unknown table");
9601        assert!(code_only.details.is_none());
9602
9603        let dynamic = ClientError::from(
9604            "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
9605        );
9606        assert_eq!(dynamic.code, "sync.invalid_request");
9607        assert_eq!(dynamic.message, "the authoring request is invalid");
9608        assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
9609            .as_str()
9610            .unwrap()
9611            .contains("patch cannot write scope column"));
9612
9613        let unclassified =
9614            ClientError::from("a commit must contain at least one operation".to_owned());
9615        assert_eq!(unclassified.code, "client.failed");
9616        assert_eq!(unclassified.message, "the local authoring operation failed");
9617        assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
9618            .as_str()
9619            .unwrap()
9620            .contains("at least one operation"));
9621
9622        // An unclassified QueryReadFailure keeps its numeric SQLite evidence.
9623        let metadata = ClientError::from(QueryReadFailure {
9624            code: None,
9625            sqlite_code: Some(1),
9626            sqlite_message: Some("generic SQLite prose".to_owned()),
9627            rollback_failure: Some(Box::new(QueryReadFailure::from(
9628                rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
9629            ))),
9630            message: "unclassified read".to_owned(),
9631        });
9632        assert_eq!(metadata.code, "client.failed");
9633        assert_eq!(metadata.message, "the local authoring operation failed");
9634        let details = metadata.details.as_ref().unwrap();
9635        assert_eq!(details["sqliteCode"], 1);
9636        assert_eq!(details["sqliteMessage"], "generic SQLite prose");
9637        assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
9638        assert_eq!(details["legacyCause"], "unclassified read");
9639    }
9640
9641    #[test]
9642    fn schema_marker_read_keeps_storage_classification() {
9643        let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9644            rusqlite::ffi::Error::new(5),
9645            Some("database is locked".to_owned()),
9646        ));
9647        assert!(busy.starts_with("client.storage_busy:"), "{busy}");
9648        let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9649            rusqlite::ffi::Error::new(1),
9650            Some("unreadable".to_owned()),
9651        ));
9652        assert_eq!(
9653            invalid,
9654            "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
9655        );
9656
9657        // A real exclusive lock on the marker read surfaces BUSY, not corrupt,
9658        // and the guard writes nothing.
9659        let path =
9660            std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
9661        let setup = Connection::open(&path).expect("setup connection");
9662        setup
9663            .execute_batch(
9664                "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n                 INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
9665            )
9666            .unwrap();
9667        let locker = Connection::open(&path).expect("locker connection");
9668        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9669        let reader = Connection::open(&path).expect("reader connection");
9670        reader.busy_timeout(std::time::Duration::ZERO).unwrap();
9671        let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
9672        assert!(locked.starts_with("client.storage_busy:"), "{locked}");
9673        locker.execute_batch("ROLLBACK").unwrap();
9674        assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
9675        let rows = setup
9676            .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
9677                row.get::<_, i64>(0)
9678            })
9679            .unwrap();
9680        assert_eq!(rows, 1, "the guard performed no writes");
9681        drop(reader);
9682        drop(locker);
9683        drop(setup);
9684        let _ = std::fs::remove_file(&path);
9685    }
9686
9687    #[cfg(feature = "crdt-yjs")]
9688    #[test]
9689    fn crdt_authoring_pre_read_preserves_storage_classification() {
9690        let path =
9691            std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
9692        let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
9693            {"name":"id","type":"string","nullable":false},
9694            {"name":"project_id","type":"string","nullable":false},
9695            {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
9696            "scopes":[{"pattern":"project:{project_id}"}]}]});
9697        let mut client = SyncClient::open_path(
9698            "crdt-busy".into(),
9699            &schema,
9700            ClientLimits::default(),
9701            path.to_str().unwrap(),
9702        )
9703        .expect("open client");
9704        client.create_synced_tables().expect("create synced tables");
9705        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
9706
9707        let locker = Connection::open(&path).expect("locker connection");
9708        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9709        let busy = client
9710            .crdt_apply_update("notes", "n1", "doc", &[])
9711            .expect_err("busy crdt pre-read");
9712        assert_eq!(busy.code, "client.storage_busy");
9713        assert!(busy.retryable);
9714        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
9715        assert!(client.pending_commit_ids().is_empty());
9716        assert_eq!(client.local_revision(), 0);
9717
9718        locker.execute_batch("ROLLBACK").unwrap();
9719        drop(locker);
9720        drop(client);
9721        let _ = std::fs::remove_file(&path);
9722    }
9723
9724    #[test]
9725    fn large_unique_import_copies_no_unrelated_tables() {
9726        let (mut client, image, table) = unique_import_fixture(10240);
9727        let pending = client.pending_commit_ids();
9728        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
9729            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
9730        assert!(matches!(
9731            client.apply_sqlite_image(
9732                &image,
9733                &table,
9734                true,
9735                &[("project_id".into(), vec!["p1".into()])],
9736                10240,
9737                7,
9738                "digest"
9739            ),
9740            Ok(10240)
9741        ));
9742        assert_eq!(client.overlay_rebuild_count.get(), 0);
9743        assert_eq!(client.pending_commit_ids(), pending);
9744        assert_eq!(
9745            client
9746                .query("SELECT count(*) AS n FROM tasks", &[])
9747                .unwrap()[0]["n"],
9748            10241
9749        );
9750        assert_eq!(
9751            client
9752                .query("SELECT count(*) AS n FROM tasks_fts", &[])
9753                .unwrap()[0]["n"],
9754            10241
9755        );
9756        assert_eq!(
9757            client
9758                .query("SELECT count(*) AS n FROM notes_fts", &[])
9759                .unwrap()[0]["n"],
9760            8192
9761        );
9762    }
9763
9764    #[test]
9765    #[ignore = "manual scaling benchmark; see bench/README.md"]
9766    fn benchmark_unique_image_import() {
9767        let mut samples = Vec::new();
9768        for rows in [10240, 51200, 102400] {
9769            for trial in 0..4 {
9770                let (mut client, image, table) = unique_import_fixture(rows);
9771                let start = std::time::Instant::now();
9772                assert!(
9773                    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)
9774                );
9775                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9776                assert_eq!(
9777                    client
9778                        .query("SELECT count(*) AS n FROM tasks", &[])
9779                        .unwrap()[0]["n"],
9780                    rows + 1
9781                );
9782                assert_eq!(
9783                    client
9784                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
9785                        .unwrap()[0]["n"],
9786                    rows + 1
9787                );
9788                assert_eq!(
9789                    client
9790                        .query("SELECT count(*) AS n FROM notes_fts", &[])
9791                        .unwrap()[0]["n"],
9792                    8192
9793                );
9794                assert_eq!(client.pending_commit_ids().len(), 1);
9795                if trial > 0 {
9796                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9797                }
9798            }
9799        }
9800        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});
9801        let output = serde_json::to_string_pretty(&artifact).unwrap();
9802        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9803            use std::io::Write;
9804            let mut file = std::fs::OpenOptions::new()
9805                .write(true)
9806                .create_new(true)
9807                .open(path)
9808                .unwrap();
9809            file.write_all(output.as_bytes()).unwrap();
9810        } else {
9811            println!("{output}");
9812        }
9813    }
9814
9815    #[test]
9816    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9817        let schema = json!({
9818            "version": 1,
9819            "tables": [{
9820                "name": "catalogue_codes",
9821                "primaryKey": "id",
9822                "columns": [
9823                    { "name": "id", "type": "string", "nullable": false },
9824                    { "name": "release_id", "type": "string", "nullable": false },
9825                    { "name": "code", "type": "string", "nullable": false },
9826                    { "name": "title", "type": "string", "nullable": false }
9827                ],
9828                "scopes": [{ "pattern": "release:{release_id}" }],
9829                "ftsIndexes": [{
9830                    "name": "catalogue_codes_fts",
9831                    "columns": ["code", "title"],
9832                    "tokenize": "unicode61 remove_diacritics 2"
9833                }]
9834            }]
9835        });
9836        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9837            .expect("FTS5 client");
9838        let insert_trigger: String = client
9839            .conn
9840            .query_row(
9841                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9842                [],
9843                |row| row.get(0),
9844            )
9845            .expect("insert trigger");
9846        let replace_guard: String = client
9847            .conn
9848            .query_row(
9849                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9850                [],
9851                |row| row.get(0),
9852            )
9853            .expect("replace guard");
9854        assert!(!insert_trigger.contains("DELETE FROM"));
9855        assert!(replace_guard.contains("BEFORE INSERT"));
9856        assert!(replace_guard.contains("WHEN EXISTS"));
9857        let search = |client: &SyncClient, query: &str| {
9858            client
9859                .query(
9860                    "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",
9861                    &[Value::from(query)],
9862                )
9863                .expect("FTS query")
9864        };
9865
9866        client
9867            .mutate(vec![Mutation::Upsert {
9868                table: "catalogue_codes".to_owned(),
9869                values: Map::from_iter([
9870                    ("id".to_owned(), Value::from("c1")),
9871                    ("release_id".to_owned(), Value::from("r1")),
9872                    ("code".to_owned(), Value::from("A01")),
9873                    ("title".to_owned(), Value::from("Cholera")),
9874                ]),
9875                base_version: None,
9876            }])
9877            .expect("insert code");
9878        assert_eq!(search(&client, "cholera").len(), 1);
9879        let prior = client
9880            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9881            .unwrap();
9882        client
9883            .conn
9884            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9885            .unwrap();
9886        client.create_synced_tables().unwrap();
9887        assert_eq!(
9888            client
9889                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9890                .unwrap(),
9891            prior
9892        );
9893        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();
9894        let rendered = serde_json::to_string(&plan).unwrap();
9895        assert!(rendered.contains("COVERING INDEX"));
9896        assert!(rendered.contains("INDEX 0:="));
9897        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9898        assert_eq!(search(&client, "cholera").len(), 1);
9899
9900        client
9901            .mutate(vec![Mutation::Upsert {
9902                table: "catalogue_codes".to_owned(),
9903                values: Map::from_iter([
9904                    ("id".to_owned(), Value::from("c1")),
9905                    ("release_id".to_owned(), Value::from("r1")),
9906                    ("code".to_owned(), Value::from("A01")),
9907                    ("title".to_owned(), Value::from("Enteric infection")),
9908                ]),
9909                base_version: None,
9910            }])
9911            .expect("update code");
9912        assert!(search(&client, "cholera").is_empty());
9913        assert_eq!(search(&client, "enteric").len(), 1);
9914
9915        client
9916            .mutate(vec![Mutation::Delete {
9917                table: "catalogue_codes".to_owned(),
9918                row_id: "c1".to_owned(),
9919                base_version: None,
9920            }])
9921            .expect("delete code");
9922        assert!(search(&client, "enteric").is_empty());
9923    }
9924
9925    #[test]
9926    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9927        let schema = json!({
9928            "version": 1,
9929            "tables": [{
9930                "name": "patient_notes",
9931                "primaryKey": "id",
9932                "columns": [
9933                    { "name": "id", "type": "string", "nullable": false },
9934                    { "name": "practice_id", "type": "string", "nullable": false },
9935                    { "name": "encryption_key_id", "type": "string", "nullable": false },
9936                    { "name": "title", "type": "string", "nullable": false }
9937                ],
9938                "scopes": [{ "pattern": "practice:{practice_id}" }],
9939                "ftsIndexes": [{
9940                    "name": "patient_notes_fts",
9941                    "columns": ["title"],
9942                    "tokenize": "unicode61 remove_diacritics 2"
9943                }]
9944            }]
9945        });
9946        let mut client = SyncClient::new(
9947            "local-purge-test".to_owned(),
9948            &schema,
9949            ClientLimits::default(),
9950        )
9951        .expect("local purge client");
9952        client
9953            .conn
9954            .execute(
9955                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9956                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9957                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9958                [],
9959            )
9960            .expect("seed base rows");
9961        client.overlay_dirty.set(true);
9962        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9963
9964        let note = |id: &str, key_id: &str, title: &str| {
9965            Map::from_iter([
9966                ("id".to_owned(), Value::from(id)),
9967                ("practice_id".to_owned(), Value::from("practice-1")),
9968                ("encryption_key_id".to_owned(), Value::from(key_id)),
9969                ("title".to_owned(), Value::from(title)),
9970            ])
9971        };
9972        let doomed = client
9973            .mutate(vec![
9974                Mutation::Upsert {
9975                    table: "patient_notes".to_owned(),
9976                    values: note("target", "key-revoked", "Target changed"),
9977                    base_version: None,
9978                },
9979                Mutation::Upsert {
9980                    table: "patient_notes".to_owned(),
9981                    values: note("unrelated", "key-held", "Unrelated changed"),
9982                    base_version: None,
9983                },
9984            ])
9985            .expect("doomed commit");
9986        let kept = client
9987            .mutate(vec![Mutation::Upsert {
9988                table: "patient_notes".to_owned(),
9989                values: note("kept", "key-held", "Kept optimistic"),
9990                base_version: None,
9991            }])
9992            .expect("kept commit");
9993        client.drain_change_batches();
9994
9995        let input = LocalDataPurgeInput {
9996            purge_id: "purge-001".to_owned(),
9997            targets: vec![LocalDataPurgeTarget {
9998                table: "patient_notes".to_owned(),
9999                selectors: BTreeMap::from([(
10000                    "encryption_key_id".to_owned(),
10001                    vec!["key-revoked".to_owned()],
10002                )]),
10003            }],
10004        };
10005        assert_eq!(
10006            client.purge_local_data(&input).expect("apply purge"),
10007            LocalDataPurgeResult {
10008                already_applied: false,
10009                purged_rows: 1,
10010                dropped_commits: 1,
10011            }
10012        );
10013        let rows = client
10014            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
10015            .expect("visible rows");
10016        assert_eq!(rows.len(), 2);
10017        assert_eq!(rows[0]["id"], "kept");
10018        assert_eq!(rows[1]["id"], "unrelated");
10019        assert_eq!(rows[1]["title"], "Unrelated original");
10020        let fts = client
10021            .query(
10022                "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'",
10023                &[],
10024            )
10025            .expect("fts query");
10026        assert!(fts.is_empty());
10027        assert_eq!(client.outbox.len(), 1);
10028        assert_eq!(client.outbox[0].client_commit_id, kept);
10029        let outcome = client
10030            .commit_outcome(&doomed)
10031            .expect("read doomed outcome")
10032            .expect("doomed outcome");
10033        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
10034        match &outcome.results[0] {
10035            CommitOperationOutcome::Error { rejection } => {
10036                assert_eq!(rejection.code, "client.local_data_purged");
10037                assert_eq!(rejection.client_commit_id, doomed);
10038            }
10039            other => panic!("expected local purge rejection, got {other:?}"),
10040        }
10041        assert_eq!(client.drain_change_batches().len(), 1);
10042        assert_eq!(
10043            client.purge_local_data(&input).expect("retry purge"),
10044            LocalDataPurgeResult {
10045                already_applied: true,
10046                purged_rows: 0,
10047                dropped_commits: 0,
10048            }
10049        );
10050        let conflicting = LocalDataPurgeInput {
10051            purge_id: input.purge_id.clone(),
10052            targets: vec![LocalDataPurgeTarget {
10053                table: "patient_notes".to_owned(),
10054                selectors: BTreeMap::from([(
10055                    "encryption_key_id".to_owned(),
10056                    vec!["key-held".to_owned()],
10057                )]),
10058            }],
10059        };
10060        assert!(client
10061            .purge_local_data(&conflicting)
10062            .expect_err("id collision must fail")
10063            .contains("already used with a different plan"));
10064    }
10065
10066    /// Mirror `SyncClient::sync` through the split round API.
10067    fn sync_split(client: &mut SyncClient, transport: &mut dyn Transport) -> SyncOutcome {
10068        let mut next = match client.prepare_sync_round(transport.supports_url_fetch()) {
10069            Ok(prepared) => prepared,
10070            Err(outcome) => return *outcome,
10071        };
10072        loop {
10073            let completed = crate::round::test_support::drive_round(next, transport);
10074            match client.apply_sync_round(completed) {
10075                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10076                crate::AppliedSyncRound::Complete {
10077                    outcome, controls, ..
10078                } => {
10079                    if client.transport_enabled() {
10080                        for text in controls {
10081                            let _ = transport.realtime_send(&text);
10082                        }
10083                    } else {
10084                        client.disconnect_realtime(transport);
10085                    }
10086                    return outcome;
10087                }
10088            }
10089        }
10090    }
10091
10092    #[derive(Clone)]
10093    enum RoundReplyScript {
10094        Response(Message),
10095        /// Answer every pushed commit with an applied result.
10096        PushAck,
10097        Error(TransportError),
10098    }
10099
10100    /// Serves one scripted reply and records every request, segment batch, and
10101    /// upload the round makes.
10102    struct SplitParityTransport {
10103        script: RoundReplyScript,
10104        segments: BTreeMap<String, Vec<u8>>,
10105        requests: Vec<Message>,
10106        batches: Vec<Vec<String>>,
10107        uploads: Vec<String>,
10108        sync_calls: usize,
10109        realtime_sync_calls: usize,
10110    }
10111
10112    impl SplitParityTransport {
10113        fn new(script: RoundReplyScript) -> Self {
10114            Self {
10115                script,
10116                segments: BTreeMap::new(),
10117                requests: Vec::new(),
10118                batches: Vec::new(),
10119                uploads: Vec::new(),
10120                sync_calls: 0,
10121                realtime_sync_calls: 0,
10122            }
10123        }
10124
10125        fn with_segments(script: RoundReplyScript, segments: BTreeMap<String, Vec<u8>>) -> Self {
10126            Self {
10127                segments,
10128                ..Self::new(script)
10129            }
10130        }
10131
10132        fn respond(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10133            let message =
10134                decode_message(request).expect("the client encodes a legal round request");
10135            self.requests.push(message.clone());
10136            match &self.script {
10137                RoundReplyScript::Response(response) => Ok(encode_message(response)),
10138                RoundReplyScript::Error(error) => Err(error.clone()),
10139                RoundReplyScript::PushAck => {
10140                    let mut frames = vec![Frame::RespHeader {
10141                        required_schema_version: None,
10142                        latest_schema_version: None,
10143                        log_epoch: Some("epoch-1".into()),
10144                        reset_required: Some(false),
10145                    }];
10146                    let mut commit_seq = 0;
10147                    for frame in &message.frames {
10148                        if let Frame::PushCommit {
10149                            client_commit_id,
10150                            operations,
10151                        } = frame
10152                        {
10153                            commit_seq += 1;
10154                            frames.push(Frame::PushResult {
10155                                client_commit_id: client_commit_id.clone(),
10156                                status: PushStatus::Applied,
10157                                commit_seq: Some(commit_seq),
10158                                results: (0..operations.len())
10159                                    .map(|op_index| OpResult::Applied {
10160                                        op_index: op_index as i32,
10161                                    })
10162                                    .collect(),
10163                            });
10164                        }
10165                    }
10166                    frames.push(Frame::SubStart {
10167                        id: "tasks".into(),
10168                        status: SubStatus::Active,
10169                        reason_code: String::new(),
10170                        effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10171                        bootstrap: false,
10172                    });
10173                    frames.push(Frame::SubEnd {
10174                        next_cursor: 0,
10175                        bootstrap_state: None,
10176                    });
10177                    Ok(encode_message(&Message {
10178                        wire_version: WIRE_VERSION,
10179                        msg_kind: MsgKind::Response,
10180                        frames,
10181                    }))
10182                }
10183            }
10184        }
10185    }
10186
10187    impl Transport for SplitParityTransport {
10188        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10189            self.sync_calls += 1;
10190            self.respond(request)
10191        }
10192
10193        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10194            self.realtime_sync_calls += 1;
10195            self.respond(request)
10196        }
10197        fn download_segment(
10198            &mut self,
10199            _request: &SegmentRequest,
10200            _on_progress: &mut dyn FnMut(u64),
10201        ) -> Result<Vec<u8>, TransportError> {
10202            Err(TransportError::new(
10203                "sync.transport_failed",
10204                "per-descriptor fetch",
10205            ))
10206        }
10207
10208        fn download_segments(
10209            &mut self,
10210            fetches: &[crate::SegmentFetch],
10211            on_progress: &mut (dyn FnMut(usize, u64) + Send),
10212        ) -> Vec<Result<Vec<u8>, TransportError>> {
10213            self.batches.push(
10214                fetches
10215                    .iter()
10216                    .map(|fetch| match fetch {
10217                        crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
10218                        crate::SegmentFetch::Url(url) => url.clone(),
10219                    })
10220                    .collect(),
10221            );
10222            fetches
10223                .iter()
10224                .enumerate()
10225                .map(|(index, fetch)| {
10226                    let crate::SegmentFetch::Direct(request) = fetch else {
10227                        panic!("the descriptors are direct-endpoint requests")
10228                    };
10229                    let bytes =
10230                        self.segments
10231                            .get(&request.segment_id)
10232                            .cloned()
10233                            .ok_or_else(|| {
10234                                TransportError::new("transport.not_found", "missing segment body")
10235                            })?;
10236                    on_progress(index, bytes.len() as u64);
10237                    Ok(bytes)
10238                })
10239                .collect()
10240        }
10241
10242        fn blob_upload_grant(
10243            &mut self,
10244            _blob_id: &str,
10245            _byte_length: u64,
10246            _media_type: Option<&str>,
10247        ) -> Result<BlobUploadGrant, TransportError> {
10248            Ok(BlobUploadGrant::None)
10249        }
10250
10251        fn blob_upload(
10252            &mut self,
10253            blob_id: &str,
10254            _bytes: &[u8],
10255            _media_type: Option<&str>,
10256        ) -> Result<(), TransportError> {
10257            self.uploads.push(blob_id.to_owned());
10258            Ok(())
10259        }
10260
10261        fn realtime_connect(&mut self) -> Result<(), TransportError> {
10262            Ok(())
10263        }
10264
10265        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
10266            Ok(())
10267        }
10268
10269        fn realtime_close(&mut self) -> Result<(), TransportError> {
10270            Ok(())
10271        }
10272    }
10273
10274    fn parity_clients() -> (SyncClient, SyncClient) {
10275        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10276            {"name":"id","type":"string","nullable":false},
10277            {"name":"project_id","type":"string","nullable":false}],
10278            "scopes":[{"pattern":"project:{project_id}"}]}]});
10279        let [exchange, split] = ["parity-exchange", "parity-split"].map(|id| {
10280            let mut client =
10281                SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10282            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10283            client
10284                .subscribe(
10285                    "tasks".into(),
10286                    "tasks".into(),
10287                    vec![("project_id".into(), vec!["p1".into()])],
10288                    None,
10289                )
10290                .unwrap();
10291            client.subs[0].cursor = 0;
10292            client.subs[0].synced_once = true;
10293            client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
10294            client.persist_sub(&client.subs[0]).unwrap();
10295            client.drain_change_batches();
10296            client
10297        });
10298        (exchange, split)
10299    }
10300
10301    fn pull_response(client: &SyncClient) -> Message {
10302        let row = encode_row_json(
10303            client.schema.table("tasks").unwrap(),
10304            "kept",
10305            &Map::from_iter([
10306                ("id".into(), json!("kept")),
10307                ("project_id".into(), json!("p1")),
10308            ]),
10309            &client.encryption,
10310        )
10311        .unwrap();
10312        Message {
10313            wire_version: WIRE_VERSION,
10314            msg_kind: MsgKind::Response,
10315            frames: vec![
10316                Frame::RespHeader {
10317                    required_schema_version: None,
10318                    latest_schema_version: None,
10319                    log_epoch: Some("epoch-1".into()),
10320                    reset_required: Some(false),
10321                },
10322                Frame::SubStart {
10323                    id: "tasks".into(),
10324                    status: SubStatus::Active,
10325                    reason_code: String::new(),
10326                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10327                    bootstrap: false,
10328                },
10329                Frame::Commit {
10330                    commit_seq: 1,
10331                    created_at_ms: 1,
10332                    actor_id: "writer".into(),
10333                    tables: vec!["tasks".into()],
10334                    changes: vec![ssp2::model::Change {
10335                        table_index: 0,
10336                        row_id: "kept".into(),
10337                        op: Op::Upsert,
10338                        row_version: Some(1),
10339                        scopes: vec![("project_id".into(), "p1".into())],
10340                        row: Some(row),
10341                    }],
10342                },
10343                Frame::SubEnd {
10344                    next_cursor: 1,
10345                    bootstrap_state: None,
10346                },
10347            ],
10348        }
10349    }
10350
10351    #[test]
10352    fn split_round_matches_exchange_for_a_pull() {
10353        let (mut exchange, mut split) = parity_clients();
10354        let response = pull_response(&exchange);
10355        let mut exchange_transport =
10356            SplitParityTransport::new(RoundReplyScript::Response(response.clone()));
10357        let mut split_transport = SplitParityTransport::new(RoundReplyScript::Response(response));
10358
10359        let exchange_outcome = exchange.sync(&mut exchange_transport);
10360        let split_outcome = sync_split(&mut split, &mut split_transport);
10361        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10362        assert_eq!(
10363            exchange
10364                .query("SELECT id, project_id FROM tasks", &[])
10365                .unwrap(),
10366            split
10367                .query("SELECT id, project_id FROM tasks", &[])
10368                .unwrap()
10369        );
10370        assert_eq!(exchange_transport.requests.len(), 1);
10371        assert_eq!(split_transport.requests.len(), 1);
10372    }
10373
10374    #[test]
10375    fn split_round_matches_exchange_for_a_push_with_an_upload() {
10376        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
10377            {"name":"id","type":"string","nullable":false},
10378            {"name":"project_id","type":"string","nullable":false},
10379            {"name":"file","type":"blob_ref","nullable":true}],
10380            "scopes":[{"pattern":"project:{project_id}"}]}]});
10381        let [mut exchange, mut split] = ["parity-push-exchange", "parity-push-split"].map(|id| {
10382            let mut client =
10383                SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10384            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10385            client
10386                .subscribe(
10387                    "attachments".into(),
10388                    "attachments".into(),
10389                    vec![("project_id".into(), vec!["p1".into()])],
10390                    None,
10391                )
10392                .unwrap();
10393            client
10394        });
10395        let bytes = b"split upload";
10396        let mut mutations = Vec::new();
10397        for client in [&mut exchange, &mut split] {
10398            client
10399                .upload_blob(bytes, Some("text/plain".into()), None)
10400                .unwrap();
10401            mutations.push(
10402                client
10403                    .mutate(vec![Mutation::Upsert {
10404                        table: "attachments".into(),
10405                        values: Map::from_iter([
10406                            ("id".into(), Value::from("a1")),
10407                            ("project_id".into(), Value::from("p1")),
10408                            (
10409                                "file".into(),
10410                                Value::from(format!(
10411                                    "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
10412                                    blob_id_for(bytes),
10413                                    bytes.len()
10414                                )),
10415                            ),
10416                        ]),
10417                        base_version: None,
10418                    }])
10419                    .unwrap(),
10420            );
10421        }
10422        let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10423        let mut split_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10424        let exchange_outcome = exchange.sync(&mut exchange_transport);
10425        let split_outcome = sync_split(&mut split, &mut split_transport);
10426        let SyncOutcome::Ok(exchange_report) = exchange_outcome else {
10427            panic!("the exchange round must succeed: {exchange_outcome:?}")
10428        };
10429        let SyncOutcome::Ok(split_report) = split_outcome else {
10430            panic!("the split round must succeed: {split_outcome:?}")
10431        };
10432        assert_eq!(exchange_report.pushed, 1);
10433        assert_eq!(split_report.pushed, 1);
10434        assert_eq!(
10435            exchange_report.commits_applied,
10436            split_report.commits_applied
10437        );
10438        assert_eq!(
10439            exchange_transport.uploads,
10440            [blob_id_for(bytes)],
10441            "one upload before the metadata request"
10442        );
10443        assert_eq!(split_transport.uploads, exchange_transport.uploads);
10444        assert!(exchange.pending_commit_ids().is_empty());
10445        assert!(split.pending_commit_ids().is_empty());
10446        assert_eq!(
10447            exchange
10448                .query("SELECT id, project_id FROM attachments", &[])
10449                .unwrap(),
10450            split
10451                .query("SELECT id, project_id FROM attachments", &[])
10452                .unwrap()
10453        );
10454    }
10455
10456    #[test]
10457    fn split_round_matches_exchange_for_a_segment_download() {
10458        let (mut exchange, mut split) = parity_clients();
10459        let columns = exchange.schema.table("tasks").unwrap().wire_columns.clone();
10460        let page = ssp2::segment::encode_rows_segment(&RowsSegment {
10461            table: "tasks".into(),
10462            schema_version: 1,
10463            columns,
10464            blocks: vec![vec![ssp2::segment::SegmentRow {
10465                server_version: 1,
10466                values: vec![
10467                    Some(ColumnValue::String("page".into())),
10468                    Some(ColumnValue::String("p1".into())),
10469                ],
10470            }]],
10471        });
10472        let segment_id = format!("sha256:{}", bytes_to_hex(&Sha256::digest(&page)));
10473        let response = Message {
10474            wire_version: WIRE_VERSION,
10475            msg_kind: MsgKind::Response,
10476            frames: vec![
10477                Frame::RespHeader {
10478                    required_schema_version: None,
10479                    latest_schema_version: None,
10480                    log_epoch: Some("epoch-1".into()),
10481                    reset_required: Some(false),
10482                },
10483                Frame::SubStart {
10484                    id: "tasks".into(),
10485                    status: SubStatus::Active,
10486                    reason_code: String::new(),
10487                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10488                    bootstrap: true,
10489                },
10490                Frame::SegmentRef {
10491                    segment_id: segment_id.clone(),
10492                    media_type: MediaType::Rows,
10493                    table: "tasks".into(),
10494                    byte_length: page.len() as i64,
10495                    row_count: 1,
10496                    as_of_commit_seq: 1,
10497                    scope_digest: "digest".into(),
10498                    row_cursor: None,
10499                    next_row_cursor: None,
10500                    url: None,
10501                    url_expires_at_ms: None,
10502                },
10503                Frame::SubEnd {
10504                    next_cursor: 1,
10505                    bootstrap_state: None,
10506                },
10507            ],
10508        };
10509        let segments = BTreeMap::from([(segment_id.clone(), page)]);
10510        let mut exchange_transport = SplitParityTransport::with_segments(
10511            RoundReplyScript::Response(response.clone()),
10512            segments.clone(),
10513        );
10514        let mut split_transport =
10515            SplitParityTransport::with_segments(RoundReplyScript::Response(response), segments);
10516        let exchange_outcome = exchange.sync(&mut exchange_transport);
10517        let split_outcome = sync_split(&mut split, &mut split_transport);
10518        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10519        assert_eq!(exchange_transport.batches, [vec![segment_id.clone()]]);
10520        assert_eq!(split_transport.batches, exchange_transport.batches);
10521        assert_eq!(
10522            exchange
10523                .query("SELECT id, project_id FROM tasks", &[])
10524                .unwrap(),
10525            split
10526                .query("SELECT id, project_id FROM tasks", &[])
10527                .unwrap()
10528        );
10529    }
10530
10531    #[test]
10532    fn split_round_matches_exchange_for_a_transport_failure() {
10533        let (mut exchange, mut split) = parity_clients();
10534        let error = TransportError::new("transport.timeout", "injected");
10535        let mut exchange_transport =
10536            SplitParityTransport::new(RoundReplyScript::Error(error.clone()));
10537        let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10538        let exchange_outcome = exchange.sync(&mut exchange_transport);
10539        let split_outcome = sync_split(&mut split, &mut split_transport);
10540        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10541        assert!(matches!(
10542            split_outcome,
10543            SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
10544        ));
10545        assert_eq!(
10546            exchange.query("SELECT id FROM tasks", &[]).unwrap(),
10547            split.query("SELECT id FROM tasks", &[]).unwrap()
10548        );
10549    }
10550
10551    #[test]
10552    fn split_round_matches_exchange_for_a_failed_realtime_round() {
10553        let (mut exchange, mut split) = parity_clients();
10554        let error = TransportError::new("transport.timeout", "socket round failed");
10555        let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10556        let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(
10557            TransportError::new("transport.timeout", "socket round failed"),
10558        ));
10559        exchange.connect_realtime(&mut exchange_transport).unwrap();
10560        split.connect_realtime(&mut split_transport).unwrap();
10561        let exchange_outcome = exchange.sync(&mut exchange_transport);
10562        let split_outcome = sync_split(&mut split, &mut split_transport);
10563        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10564        assert_eq!(exchange_transport.realtime_sync_calls, 1);
10565        assert_eq!(exchange_transport.sync_calls, 0);
10566        assert_eq!(split_transport.realtime_sync_calls, 1);
10567        assert_eq!(split_transport.sync_calls, 0);
10568        assert_eq!(exchange.realtime_state(), RealtimeState::Lost);
10569        assert_eq!(split.realtime_state(), RealtimeState::Lost);
10570    }
10571}
10572
10573impl SubState {
10574    fn name(self) -> &'static str {
10575        match self {
10576            SubState::Active => "active",
10577            SubState::Revoked => "revoked",
10578            SubState::Failed => "failed",
10579        }
10580    }
10581
10582    fn parse(value: &str) -> Self {
10583        match value {
10584            "revoked" => Self::Revoked,
10585            "failed" => Self::Failed,
10586            _ => Self::Active,
10587        }
10588    }
10589}
10590
10591#[derive(Debug, Clone)]
10592struct Subscription {
10593    id: String,
10594    table: String,
10595    requested: Vec<(String, Vec<String>)>,
10596    params: Option<String>,
10597    cursor: i64,
10598    /// §4.7 resume token, round-tripped opaquely.
10599    bootstrap_state: Option<String>,
10600    state: SubState,
10601    reason_code: Option<String>,
10602    /// Last effective scopes echoed while active (§3.3: persisted for the
10603    /// purge contract; each active echo replaces it).
10604    effective: Option<Vec<(String, Vec<String>)>>,
10605    synced_once: bool,
10606}
10607
10608#[cfg(test)]
10609mod subscription_rearm_tests {
10610    use super::*;
10611    use serde_json::json;
10612
10613    fn client() -> SyncClient {
10614        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10615            {"name":"id","type":"string","nullable":false},
10616            {"name":"project_id","type":"string","nullable":false}],
10617            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
10618        SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
10619    }
10620
10621    fn scopes() -> Vec<(String, Vec<String>)> {
10622        vec![("project_id".to_owned(), vec!["p1".to_owned()])]
10623    }
10624
10625    /// §3.3: the stored registration carries progress the re-arm must drop.
10626    fn store_revoked(client: &mut SyncClient) {
10627        client
10628            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10629            .expect("subscribe");
10630        let sub = &mut client.subs[0];
10631        sub.cursor = 41;
10632        sub.bootstrap_state = Some("resume".to_owned());
10633        sub.effective = Some(scopes());
10634        sub.state = SubState::Revoked;
10635        sub.reason_code = Some("sync.scope_revoked".to_owned());
10636        client.persist_sub(&client.subs[0]).expect("persist");
10637        client.sync_needed = false;
10638        client.sync_intent_queue.clear();
10639    }
10640
10641    #[test]
10642    fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
10643        let mut client = client();
10644        store_revoked(&mut client);
10645
10646        client
10647            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10648            .expect("re-arm");
10649
10650        let view = client.subscription_state("sub").expect("state");
10651        assert_eq!(view.status, "active");
10652        assert_eq!(view.cursor, -1);
10653        assert!(!view.has_resume_token);
10654        assert_eq!(view.effective_scopes, None);
10655        assert_eq!(view.reason_code, None);
10656        assert!(client.sync_needed, "a re-arm asks for a pull");
10657        assert!(matches!(
10658            client.drain_sync_intents().as_slice(),
10659            [SyncIntent::Interactive]
10660        ));
10661        let stored: String = client
10662            .conn
10663            .query_row(
10664                "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
10665                [],
10666                |row| row.get(0),
10667            )
10668            .expect("persisted row");
10669        let stored: Value = serde_json::from_str(&stored).expect("stored json");
10670        assert_eq!(stored["status"], json!("active"));
10671        assert_eq!(stored["cursor"], json!(-1));
10672        assert_eq!(stored["bootstrapState"], Value::Null);
10673        assert_eq!(stored["effectiveScopes"], Value::Null);
10674        assert_eq!(stored["reasonCode"], Value::Null);
10675    }
10676
10677    #[test]
10678    fn a_failed_registration_is_not_rearmed() {
10679        let mut client = client();
10680        client
10681            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10682            .expect("subscribe");
10683        client.subs[0].state = SubState::Failed;
10684        client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
10685        client.persist_sub(&client.subs[0]).expect("persist");
10686        client.sync_needed = false;
10687        client.sync_intent_queue.clear();
10688
10689        client
10690            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10691            .expect("subscribe");
10692
10693        let view = client.subscription_state("sub").expect("state");
10694        assert_eq!(view.status, "failed");
10695        assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
10696        assert!(!client.sync_needed);
10697        assert!(client.drain_sync_intents().is_empty());
10698    }
10699}
10700
10701#[derive(Debug, Clone)]
10702struct OutboxOp {
10703    upsert: bool,
10704    table: String,
10705    row_id: String,
10706    base_version: Option<i64>,
10707    /// Schema-agnostic local form (§0): driver JSON values, encoded with
10708    /// the current codec at send time. A key's presence IS the §6.1 sparse
10709    /// presence set — both the overlay and the wire payload derive from it.
10710    values: Option<Map<String, Value>>,
10711}
10712
10713impl From<&OutboxOp> for CommitOperation {
10714    fn from(operation: &OutboxOp) -> Self {
10715        Self {
10716            table: operation.table.clone(),
10717            row_id: operation.row_id.clone(),
10718            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
10719            base_version: operation.base_version,
10720            values: operation.values.clone(),
10721        }
10722    }
10723}
10724
10725#[derive(Debug, Clone)]
10726struct OutboxCommit {
10727    client_commit_id: String,
10728    ops: Vec<OutboxOp>,
10729}
10730
10731/// One commit's `(table, value keys)` per upsert operation — the classification
10732/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
10733/// can never disagree about which commits cannot re-encode.
10734fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
10735    commit
10736        .ops
10737        .iter()
10738        .filter(|operation| operation.upsert)
10739        .map(|operation| {
10740            (
10741                operation.table.as_str(),
10742                operation
10743                    .values
10744                    .as_ref()
10745                    .map(|values| values.keys().map(String::as_str).collect())
10746                    .unwrap_or_default(),
10747            )
10748        })
10749        .collect()
10750}
10751
10752type FailedCommit = (OutboxCommit, Map<String, Value>);
10753
10754#[derive(Debug, Clone)]
10755struct CompiledLocalDataPurgeTarget {
10756    table: String,
10757    selectors: Vec<(String, Vec<String>)>,
10758}
10759
10760struct StoredCommitOutcomeRow {
10761    sequence: i64,
10762    client_commit_id: String,
10763    status: String,
10764    recorded_at_ms: i64,
10765    results_json: String,
10766    operations_json: Option<String>,
10767    resolution: String,
10768    resolved_at_ms: Option<i64>,
10769    replacement_client_commit_id: Option<String>,
10770}
10771
10772impl StoredCommitOutcomeRow {
10773    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
10774        Ok(Self {
10775            sequence: row.get(0)?,
10776            client_commit_id: row.get(1)?,
10777            status: row.get(2)?,
10778            recorded_at_ms: row.get(3)?,
10779            results_json: row.get(4)?,
10780            operations_json: row.get(5)?,
10781            resolution: row.get(6)?,
10782            resolved_at_ms: row.get(7)?,
10783            replacement_client_commit_id: row.get(8)?,
10784        })
10785    }
10786}
10787
10788/// Section outcome distinguishing the §5.6 subscription-local fail-closed
10789/// path from a round-aborting failure (§1.4 rule 5).
10790enum SectionError {
10791    FailClosed,
10792    Abort(String, String),
10793}
10794
10795#[derive(Debug)]
10796pub(crate) struct RequestMeta {
10797    pushed_ids: Vec<String>,
10798    /// Subscription id → the request carried `cursor < 0` and no resume
10799    /// token (§5.6 first-page detection: a *fresh* bootstrap).
10800    fresh: Vec<(String, bool)>,
10801    pub(crate) accept: u8,
10802    /// §6.1 splitBatch: outbox commits held back from THIS request because
10803    /// the running operation count reached the push cap — the next round
10804    /// pushes them (`sync_needed` stays set while any remain).
10805    deferred_commits: usize,
10806}
10807
10808#[derive(Default)]
10809struct ChangeAccumulator {
10810    /// None means table-wide; Some is the exact scoped domain.
10811    tables: BTreeMap<String, Option<BTreeSet<String>>>,
10812    windows: BTreeMap<(String, String), BTreeSet<String>>,
10813    status: bool,
10814    conflicts: bool,
10815    rejections: bool,
10816    outcomes: bool,
10817}
10818
10819impl ChangeAccumulator {
10820    fn table(&mut self, table: &str) {
10821        self.tables.insert(table.to_owned(), None);
10822    }
10823
10824    fn scope(&mut self, table: &str, key: String) {
10825        match self.tables.get_mut(table) {
10826            Some(None) => {}
10827            Some(Some(keys)) => {
10828                keys.insert(key);
10829            }
10830            None => {
10831                self.tables
10832                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
10833            }
10834        }
10835    }
10836
10837    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
10838        self.windows
10839            .entry((base_key.to_owned(), table.to_owned()))
10840            .or_default()
10841            .insert(unit.to_owned());
10842    }
10843
10844    fn touched(&self) -> bool {
10845        !self.tables.is_empty()
10846            || !self.windows.is_empty()
10847            || self.status
10848            || self.conflicts
10849            || self.rejections
10850            || self.outcomes
10851    }
10852}
10853
10854/// §6.1: the server caps total operations per request (reference default
10855/// 500) and rejects the whole batch with `sync.too_many_operations`; the
10856/// client "splits and retries". Splitting happens at build time: commits are
10857/// included IN ORDER until the operation budget is spent, the rest wait for
10858/// the next round.
10859const PUSH_OPS_PER_REQUEST: usize = 500;
10860const MAX_LOCAL_PURGE_TARGETS: usize = 64;
10861const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
10862const MAX_LOCAL_PURGE_VALUES: usize = 128;
10863const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
10864pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
10865
10866/// Dirty visible tables; `None` means a whole-replica recovery/reset.
10867struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
10868
10869impl OverlayDirty {
10870    fn clean() -> Self {
10871        Self(RefCell::new(Some(BTreeSet::new())))
10872    }
10873    fn get(&self) -> bool {
10874        self.0
10875            .borrow()
10876            .as_ref()
10877            .is_none_or(|tables| !tables.is_empty())
10878    }
10879    fn set(&self, dirty: bool) {
10880        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
10881    }
10882    fn table(&self, table: &str) {
10883        if let Some(tables) = self.0.borrow_mut().as_mut() {
10884            tables.insert(table.to_owned());
10885        }
10886    }
10887    fn snapshot(&self) -> Option<BTreeSet<String>> {
10888        self.0.borrow().clone()
10889    }
10890    fn restore(&self, tables: Option<BTreeSet<String>>) {
10891        *self.0.borrow_mut() = tables;
10892    }
10893}
10894
10895pub struct SyncClient {
10896    #[cfg(feature = "bench-internals")]
10897    benchmark_phases: Recorder,
10898    conn: Connection,
10899    schema: ClientSchema,
10900    client_id: String,
10901    limits: ClientLimits,
10902    subs: Vec<Subscription>,
10903    outbox: Vec<OutboxCommit>,
10904    failed_commits: Vec<FailedCommit>,
10905    retain_failed_commits: bool,
10906    conflicts: Vec<ConflictRecord>,
10907    rejections: Vec<RejectionRecord>,
10908    schema_floor: Option<SchemaFloor>,
10909    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
10910    lease_state: Option<LeaseState>,
10911    /// §1.6: the schema-floor response stops syncing until an upgrade.
10912    stopped: bool,
10913    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
10914    upgrading: bool,
10915    /// §8.4 coalesced sync-needed signal.
10916    sync_needed: bool,
10917    /// §8.8: how sync rounds treat the realtime binding, and its exact
10918    /// availability state with the evidence behind a lost or refused one.
10919    realtime_policy: RealtimePolicy,
10920    realtime_state: RealtimeState,
10921    realtime_reason_code: Option<String>,
10922    realtime_retry_delay_ms: Option<u64>,
10923    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
10924    presence: HashMap<String, HashMap<String, PresencePeer>>,
10925    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
10926    /// host may pin it (conformance runs on a virtual clock).
10927    now_ms: Option<i64>,
10928    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
10929    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
10930    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
10931    encryption: crate::values::EncryptionConfig,
10932    /// Fail-closed host bootstrap gate. While set, command hosts permit only
10933    /// status/lifecycle inspection and an exact authorized local purge.
10934    security_preflight: bool,
10935    transport_enabled: bool,
10936    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
10937    /// means the feature is off; the descriptor write and the container orphan
10938    /// sweep still happen.
10939    previous_version: Option<PreviousVersionContextConfig>,
10940    /// Per-table primary-key upsert SQL, built once per (full table name) —
10941    /// the row write path runs per row during bootstrap (§5.6), so the SQL
10942    /// string (and, via `prepare_cached`, its compiled statement) is reused
10943    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
10944    /// schema reset (the column lists may have changed).
10945    insert_sql: RefCell<HashMap<String, String>>,
10946    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
10947    /// reset and restart explicitly invalidate every table.
10948    overlay_dirty: OverlayDirty,
10949    // First SQLite failure of the current operation, before savepoint cleanup.
10950    storage_failure: RefCell<Option<QueryReadFailure>>,
10951    /// Test-only structural performance signal: response processing must not
10952    /// turn a batch of acknowledgements into one full overlay rebuild each.
10953    #[cfg(test)]
10954    overlay_rebuild_count: Cell<usize>,
10955    #[cfg(test)]
10956    acknowledged_replay_count: Cell<usize>,
10957    /// Test-only structural performance signal: outcome retention is enforced
10958    /// once per response rather than once per acknowledgement.
10959    #[cfg(test)]
10960    outcome_prune_count: Cell<usize>,
10961    /// Exact observer-transaction output drained by command/FFI hosts.
10962    progress: ProgressObserver,
10963    change_queue: VecDeque<ClientChangeBatch>,
10964    sync_intent_queue: VecDeque<SyncIntent>,
10965    /// Explicit exponential retry policy for transient transport failures.
10966    retry_delay_ms: u64,
10967    /// §7.6: the background retry the current round scheduled, if any.
10968    round_retry_delay_ms: Option<u64>,
10969    /// A round whose response never completed (§1.5): the client keeps its
10970    /// code and reports it on every following request until a round receives a
10971    /// complete, decodable response. The server already recorded the round, so
10972    /// nothing else can tell it the round failed on this side.
10973    round_failure: Option<String>,
10974    active_round: Option<uuid::Uuid>,
10975    last_round: Option<DiagnosticLastRound>,
10976    last_change: Option<DiagnosticLastChange>,
10977    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
10978    query_failures: Vec<DiagnosticQueryFailure>,
10979    /// SPEC §1.4 rule 4: the open coalesced apply transaction of a response.
10980    coalesced: Option<CoalescedApply>,
10981    /// A coalesced apply transaction of the current response failed; every
10982    /// later observation of that response fails until it ends.
10983    coalesced_failure: Option<String>,
10984}
10985
10986/// SPEC §1.4 rule 4: the frames and blocks of one response share one local
10987/// write transaction of at most `COALESCED_APPLY_ROWS` rows. The in-memory
10988/// state captured at its `BEGIN` is restored when its `COMMIT` fails.
10989struct CoalescedApply {
10990    rows: usize,
10991    subs: Vec<Subscription>,
10992    outbox: Vec<OutboxCommit>,
10993    failed_commits: Vec<FailedCommit>,
10994    conflicts: Vec<ConflictRecord>,
10995    rejections: Vec<RejectionRecord>,
10996    schema_floor: Option<SchemaFloor>,
10997    lease_state: Option<LeaseState>,
10998    stopped: bool,
10999    upgrading: bool,
11000    sync_needed: bool,
11001    overlay_dirty: Option<BTreeSet<String>>,
11002    change_queue: usize,
11003    sync_intent_queue: usize,
11004    last_change: Option<DiagnosticLastChange>,
11005}
11006
11007const COALESCED_APPLY_ROWS: usize = 1024;
11008
11009pub(crate) fn quote_ident(name: &str) -> String {
11010    format!("\"{}\"", name.replace('"', "\"\""))
11011}
11012/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
11013/// previous-version module reads and writes the SAME records as the client
11014/// instead of growing a parallel meta layer.
11015pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
11016    conn.query_row(
11017        "SELECT value FROM _syncular_meta WHERE key = ?1",
11018        rusqlite::params![key],
11019        |row| row.get::<_, String>(0),
11020    )
11021    .ok()
11022}
11023
11024pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
11025    let _ = conn.execute(
11026        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
11027        rusqlite::params![key, value],
11028    );
11029}
11030
11031pub(crate) fn meta_delete(conn: &Connection, key: &str) {
11032    let _ = conn.execute(
11033        "DELETE FROM _syncular_meta WHERE key = ?1",
11034        rusqlite::params![key],
11035    );
11036}
11037
11038/// Seek by the existing key while retaining the exact legacy text match.
11039fn row_id_predicate(table: &TableSchema) -> String {
11040    let key = quote_ident(&table.primary_key);
11041    let text_match = format!("CAST({key} AS TEXT) = ?1");
11042    match table.columns[table.pk_index].ty {
11043        ColumnType::String | ColumnType::Json => {
11044            format!("{key} = ?1 AND {text_match}")
11045        }
11046        // Native columns have no declared SQLite affinity. Match the stored
11047        // integer bind type explicitly. Unary + removes expression affinity so
11048        // SQLite can seek the typeless key; the text check preserves exact IDs.
11049        ColumnType::Integer | ColumnType::Boolean => {
11050            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
11051        }
11052        // REAL-to-text conversion can round distinct values to the same text.
11053        // Narrowing those matches by numeric equality would change behavior.
11054        _ => text_match,
11055    }
11056}
11057
11058fn is_local_operation_code_like(value: &str) -> bool {
11059    let bytes = value.as_bytes();
11060    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
11061        && bytes
11062            .iter()
11063            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
11064}
11065
11066fn base_table(name: &str) -> String {
11067    quote_ident(&format!("_syncular_base_{name}"))
11068}
11069
11070/// §4.8: a stable, server-opaque key for a window base — table + variable +
11071/// canonical fixed scopes. Two `set_window` calls with the same base
11072/// address the same registry rows.
11073/// §4.8 deferred eviction record: (sub id, table, effective scope map).
11074type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
11075
11076fn window_base_key(base: &WindowBase) -> String {
11077    format!(
11078        "{}\0{}\0{}",
11079        base.table,
11080        base.variable,
11081        canonical_scope_json(&base.fixed_scopes)
11082    )
11083}
11084
11085/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
11086fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
11087    let mut scopes = base.fixed_scopes.clone();
11088    scopes.retain(|(k, _)| k != &base.variable);
11089    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
11090    scopes
11091}
11092
11093/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
11094/// echoed not interpreted by the server, so the exact hash is client
11095/// convention; SHA-256 matches the SPEC's worked example.
11096fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
11097    let canonical = canonical_scope_json(&unit_scopes(base, unit));
11098    let digest = Sha256::digest(canonical.as_bytes());
11099    let hex = bytes_to_hex(&digest);
11100    format!("w:{}:{}", base.table, &hex[..16])
11101}
11102
11103fn visible_table(name: &str) -> String {
11104    quote_ident(name)
11105}
11106
11107const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
11108
11109/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
11110/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
11111fn is_synced_table_name(name: &str) -> bool {
11112    if name.starts_with("sqlite_") {
11113        return false;
11114    }
11115    if name.starts_with("_syncular_base_") {
11116        return true; // the base half of a synced table pair
11117    }
11118    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
11119    !name.starts_with("_syncular_")
11120}
11121
11122/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
11123fn blob_id_for(bytes: &[u8]) -> String {
11124    let digest = Sha256::digest(bytes);
11125    format!("sha256:{}", bytes_to_hex(&digest))
11126}
11127
11128/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
11129/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
11130/// per row on the §5.6 bootstrap path), scalars bind owned.
11131enum RowParam<'a> {
11132    Cell(&'a Option<ColumnValue>),
11133    Version(i64),
11134}
11135
11136impl rusqlite::ToSql for RowParam<'_> {
11137    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
11138        Ok(match self {
11139            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
11140            RowParam::Cell(cell) => match cell {
11141                None => ToSqlOutput::Owned(SqlValue::Null),
11142                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
11143                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
11144                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
11145                Some(ColumnValue::Boolean(b)) => {
11146                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
11147                }
11148                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
11149                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
11150                }
11151                // §5.10: crdt bytes store as BLOB, like bytes.
11152                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
11153                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
11154                }
11155            },
11156        })
11157    }
11158}
11159
11160// Retain a typed primary key across the current import chunk without copying row payloads.
11161fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
11162    match param.to_sql().map_err(|error| error.to_string())? {
11163        ToSqlOutput::Owned(value) => Ok(value),
11164        ToSqlOutput::Borrowed(value) => {
11165            SqlValue::try_from(value).map_err(|error| error.to_string())
11166        }
11167        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
11168    }
11169}
11170
11171/// §5.3 image cell → bind parameter, strict per the declared column type
11172/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
11173/// nullable). Mismatches are image-producer violations. Returns the cell
11174/// borrowed when the stored representation already matches what the row
11175/// codec would write, or the normalized scalar (boolean → 0/1, float from
11176/// INTEGER → REAL) otherwise — the local write is byte-identical to the
11177/// old convert-then-insert path without allocating per cell.
11178fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
11179    use ssp2::segment::ColumnType;
11180    let mismatch = || {
11181        Err(format!(
11182            "image column {:?} holds a value of the wrong type",
11183            column.name
11184        ))
11185    };
11186    match value {
11187        ValueRef::Null => {
11188            if !column.nullable {
11189                return Err(format!(
11190                    "image column {:?} is NULL but not nullable",
11191                    column.name
11192                ));
11193            }
11194            Ok(ToSqlOutput::Owned(SqlValue::Null))
11195        }
11196        ValueRef::Integer(i) => match column.ty {
11197            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
11198            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
11199            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
11200            _ => mismatch(),
11201        },
11202        ValueRef::Real(_) => match column.ty {
11203            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
11204            _ => mismatch(),
11205        },
11206        ValueRef::Text(t) => {
11207            std::str::from_utf8(t)
11208                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
11209            match column.ty {
11210                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
11211                    Ok(ToSqlOutput::Borrowed(value))
11212                }
11213                _ => mismatch(),
11214            }
11215        }
11216        ValueRef::Blob(_) => match column.ty {
11217            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
11218            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
11219            _ => mismatch(),
11220        },
11221    }
11222}
11223
11224pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
11225    use rusqlite::types::ValueRef;
11226    match value {
11227        ValueRef::Null => Value::Null,
11228        ValueRef::Integer(i) => match column.ty {
11229            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
11230            ssp2::segment::ColumnType::Float => {
11231                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
11232            }
11233            _ => Value::from(i),
11234        },
11235        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11236        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11237        ValueRef::Blob(b) => {
11238            let mut map = Map::new();
11239            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11240            Value::Object(map)
11241        }
11242    }
11243}
11244
11245/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
11246/// Objects are accepted in lossless `{"$bytes": hex}` and
11247/// `{"$bigint": decimal}` envelope forms.
11248fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
11249    Ok(match value {
11250        Value::Null => SqlValue::Null,
11251        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
11252        Value::Number(n) => {
11253            if let Some(i) = n.as_i64() {
11254                SqlValue::Integer(i)
11255            } else if let Some(f) = n.as_f64() {
11256                SqlValue::Real(f)
11257            } else {
11258                return Err(format!("query param number {n} is out of range"));
11259            }
11260        }
11261        Value::String(s) => SqlValue::Text(s.clone()),
11262        Value::Object(_) => {
11263            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
11264                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
11265            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
11266                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
11267                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
11268                })?)
11269            } else {
11270                return Err(
11271                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
11272                        .to_owned(),
11273                );
11274            }
11275        }
11276        Value::Array(_) => return Err("query array params are not supported".to_owned()),
11277    })
11278}
11279
11280/// Map a rusqlite value with no schema column to consult (arbitrary query
11281/// output): integers/reals/text pass through by stored affinity, blobs ride
11282/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
11283/// schema column type to recover booleans/floats/json.
11284fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
11285    use rusqlite::types::ValueRef;
11286    match value {
11287        ValueRef::Null => Value::Null,
11288        ValueRef::Integer(i) => {
11289            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
11290            // ordinary UI-sized integers ergonomic and envelope only values
11291            // beyond JavaScript's safe range.
11292            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
11293            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
11294                Value::from(i)
11295            } else {
11296                let mut map = Map::new();
11297                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
11298                Value::Object(map)
11299            }
11300        }
11301        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11302        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11303        ValueRef::Blob(b) => {
11304            let mut map = Map::new();
11305            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11306            Value::Object(map)
11307        }
11308    }
11309}
11310
11311/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
11312fn query_connection(
11313    conn: &Connection,
11314    sql: &str,
11315    params: &[QueryValue],
11316) -> Result<Vec<QueryRow>, QueryReadFailure> {
11317    crate::query_guard::assert_read_only_query(sql)?;
11318    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
11319    let bound: Vec<SqlValue> = params
11320        .iter()
11321        .map(json_param_to_sql)
11322        .collect::<Result<_, _>>()?;
11323    let mut stmt = conn.prepare(&lowered_sql)?;
11324    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
11325    let bound_refs: Vec<&dyn rusqlite::ToSql> =
11326        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
11327    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
11328    let mut out = Vec::new();
11329    while let Some(row) = sql_rows.next()? {
11330        let mut record = Map::new();
11331        for (i, name) in column_names.iter().enumerate() {
11332            let value = row.get_ref(i)?;
11333            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
11334        }
11335        out.push(record);
11336    }
11337    Ok(out)
11338}
11339
11340fn persisted_window_state(
11341    conn: &Connection,
11342    base: &WindowBase,
11343) -> Result<WindowState, QueryReadFailure> {
11344    let mut stmt = conn.prepare(
11345        "SELECT windows.unit, subscriptions.state_json
11346           FROM _syncular_windows AS windows
11347           JOIN _syncular_subscriptions AS subscriptions
11348             ON subscriptions.id = windows.sub_id
11349          WHERE windows.base = ?1
11350          ORDER BY windows.unit ASC",
11351    )?;
11352    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
11353        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11354    })?;
11355    let mut units = Vec::new();
11356    let mut pending = Vec::new();
11357    for row in rows {
11358        let (unit, raw) = row?;
11359        let state: Value = serde_json::from_str(&raw)
11360            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
11361        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
11362            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
11363            || state
11364                .get("bootstrapState")
11365                .is_some_and(|value| !value.is_null());
11366        if is_pending {
11367            pending.push(unit.clone());
11368        }
11369        units.push(unit);
11370    }
11371    Ok(WindowState { units, pending })
11372}
11373
11374/// One read-only statement in a §7.5 snapshot read.
11375#[derive(Debug, Clone, Copy)]
11376pub struct SnapshotStatement<'a> {
11377    pub sql: &'a str,
11378    pub params: &'a [Value],
11379}
11380
11381/// A §7.5 atomic read request: read-only statements, window coverage,
11382/// subscription catch-up states, and commit deliveries, all resolved from one
11383/// SQLite read transaction and one revision.
11384#[derive(Debug, Clone, Default)]
11385pub struct SnapshotReadRequest<'a> {
11386    pub statements: Vec<SnapshotStatement<'a>>,
11387    pub coverage: &'a [WindowCoverage],
11388    pub subscriptions: Vec<String>,
11389    pub commit_ids: Vec<String>,
11390}
11391
11392#[cfg(test)]
11393thread_local! {
11394    /// Test-only rendezvous that runs between the statements of one snapshot
11395    /// read. A test uses it to commit on another connection mid-read and prove
11396    /// the read transaction's snapshot and revision stay stable.
11397    static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
11398        std::cell::RefCell::new(None);
11399}
11400
11401#[cfg(test)]
11402fn run_snapshot_read_hook() {
11403    SNAPSHOT_READ_HOOK.with(|slot| {
11404        if let Some(hook) = slot.borrow_mut().take() {
11405            hook();
11406        }
11407    });
11408}
11409
11410/// §7.5: resolve a request over one SQLite read transaction. The revision,
11411/// every statement's rows, coverage, catch-up states, and deliveries are
11412/// captured under one `SAVEPOINT`; any statement's failure rolls the whole
11413/// read back and releases the connection.
11414fn snapshot_read_connection(
11415    conn: &Connection,
11416    request: &SnapshotReadRequest<'_>,
11417) -> Result<SnapshotRead, QueryReadFailure> {
11418    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
11419    let corrupt = |message: &str| QueryReadFailure {
11420        code: Some("sync.local_corrupt"),
11421        sqlite_code: None,
11422        sqlite_message: None,
11423        rollback_failure: None,
11424        message: message.to_owned(),
11425    };
11426    let stored_failure = |error: rusqlite::Error, message: &str| match error {
11427        rusqlite::Error::Utf8Error(..)
11428        | rusqlite::Error::InvalidColumnType(..)
11429        | rusqlite::Error::FromSqlConversionFailure(..)
11430        | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
11431        _ => QueryReadFailure::from(error),
11432    };
11433    let result: Result<SnapshotRead, QueryReadFailure> = (|| {
11434        // The durable revision as a canonical decimal `u64`; a missing marker
11435        // is the legacy zero, and any other non-canonical value is corrupt.
11436        let revision: Option<String> = conn
11437            .query_row(
11438                "SELECT value FROM _syncular_meta WHERE key = ?1",
11439                rusqlite::params![LOCAL_REVISION_KEY],
11440                |row| row.get(0),
11441            )
11442            .optional()
11443            .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
11444        let revision = match revision {
11445            None => "0".to_owned(),
11446            Some(value)
11447                if value == "0"
11448                    || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11449                        && value.bytes().all(|b| b.is_ascii_digit())
11450                        && value.parse::<u64>().is_ok()) =>
11451            {
11452                value
11453            }
11454            Some(_) => return Err(corrupt("persisted local revision is invalid")),
11455        };
11456        let mut queries = Vec::with_capacity(request.statements.len());
11457        for statement in &request.statements {
11458            queries.push(query_connection(conn, statement.sql, statement.params)?);
11459            #[cfg(test)]
11460            run_snapshot_read_hook();
11461        }
11462        let mut pending = Vec::new();
11463        let mut missing = Vec::new();
11464        for requested in request.coverage {
11465            let base_key = window_base_key(&requested.base);
11466            let state = persisted_window_state(conn, &requested.base)?;
11467            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
11468                let reference = WindowUnitRef {
11469                    base_key: base_key.clone(),
11470                    unit: unit.clone(),
11471                };
11472                if !state.units.iter().any(|held| held == &unit) {
11473                    missing.push(reference);
11474                } else if state.pending.iter().any(|held| held == &unit) {
11475                    pending.push(reference);
11476                }
11477            }
11478        }
11479        let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
11480        for id in &request.subscriptions {
11481            let row: Option<(String, String)> = conn
11482                .query_row(
11483                    "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
11484                    rusqlite::params![id],
11485                    |row| Ok((row.get(0)?, row.get(1)?)),
11486                )
11487                .optional()
11488                .map_err(|error| {
11489                    stored_failure(error, "persisted subscription state is invalid")
11490                })?;
11491            let Some((table, raw)) = row else {
11492                subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
11493                continue;
11494            };
11495            // Every persisted field is required to be well typed; a corrupt
11496            // record fails typed instead of inventing a state.
11497            let state: Value = serde_json::from_str(&raw)
11498                .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11499            let status = match state.get("status").and_then(Value::as_str) {
11500                Some(value @ ("active" | "revoked" | "failed")) => value,
11501                _ => return Err(corrupt("persisted subscription state is invalid")),
11502            };
11503            let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
11504                return Err(corrupt("persisted subscription state is invalid"));
11505            };
11506            if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
11507                return Err(corrupt("persisted subscription state is invalid"));
11508            }
11509            let effective_scopes = state
11510                .get("effectiveScopes")
11511                .filter(|value| !value.is_null());
11512            if let Some(scopes) = effective_scopes {
11513                json_to_scope_map(scopes)
11514                    .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11515            }
11516            let has_resume_token = match state.get("bootstrapState") {
11517                None | Some(Value::Null) => false,
11518                Some(Value::String(_)) => true,
11519                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11520            };
11521            // A present reasonCode must be a string; a non-string value is a
11522            // corrupt record, never silently dropped.
11523            let reason_code = match state.get("reasonCode") {
11524                None | Some(Value::Null) => None,
11525                Some(Value::String(value)) => Some(value.clone()),
11526                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11527            };
11528            let active = status == "active";
11529            subscriptions.push(SubscriptionCatchup::Known {
11530                id: id.clone(),
11531                table,
11532                status: status.to_owned(),
11533                cursor,
11534                has_resume_token,
11535                bootstrap_complete: active && cursor >= 0 && !has_resume_token,
11536                known_pending_pages: active && (has_resume_token || cursor < 0),
11537                effective_scopes: effective_scopes.cloned(),
11538                reason_code,
11539            });
11540        }
11541        let mut deliveries = Vec::with_capacity(request.commit_ids.len());
11542        for id in &request.commit_ids {
11543            let pending: bool = conn.query_row(
11544                "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
11545                rusqlite::params![id],
11546                |row| row.get(0),
11547            )?;
11548            if pending {
11549                deliveries.push(CommitDelivery::Pending {
11550                    client_commit_id: id.clone(),
11551                });
11552                continue;
11553            }
11554            let row = conn
11555                .query_row(
11556                    "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
11557                            resolution, resolved_at_ms, replacement_client_commit_id
11558                       FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
11559                    rusqlite::params![id],
11560                    StoredCommitOutcomeRow::from_row,
11561                )
11562                .optional()
11563                .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
11564            deliveries.push(match row {
11565                Some(row) => CommitDelivery::Known {
11566                    client_commit_id: id.clone(),
11567                    outcome: SyncClient::outcome_from_row(row)
11568                        .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
11569                },
11570                None => CommitDelivery::Unknown {
11571                    client_commit_id: id.clone(),
11572                },
11573            });
11574        }
11575        Ok(SnapshotRead {
11576            revision,
11577            queries,
11578            coverage: CoverageSnapshot {
11579                complete: pending.is_empty() && missing.is_empty(),
11580                pending,
11581                missing,
11582            },
11583            subscriptions,
11584            deliveries,
11585        })
11586    })();
11587    match result {
11588        Ok(read) => {
11589            conn.execute_batch("RELEASE syncular_snapshot_read")?;
11590            Ok(read)
11591        }
11592        Err(mut error) => {
11593            if let Err(rollback) = conn
11594                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
11595            {
11596                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
11597            }
11598            Err(error)
11599        }
11600    }
11601}
11602
11603fn snapshot_connection(
11604    conn: &Connection,
11605    sql: &str,
11606    params: &[Value],
11607    coverage: &[WindowCoverage],
11608) -> Result<QuerySnapshot, QueryReadFailure> {
11609    let read = snapshot_read_connection(
11610        conn,
11611        &SnapshotReadRequest {
11612            statements: vec![SnapshotStatement { sql, params }],
11613            coverage,
11614            subscriptions: Vec::new(),
11615            commit_ids: Vec::new(),
11616        },
11617    )?;
11618    Ok(QuerySnapshot {
11619        revision: read.revision,
11620        rows: read
11621            .queries
11622            .into_iter()
11623            .next()
11624            .expect("single-query snapshot returns one result"),
11625        coverage: read.coverage,
11626    })
11627}
11628
11629/// A long-lived read-only SQLite sidecar for latency-critical native views.
11630/// Network rounds stay serialized on the mutable core owner, while atomic
11631/// query snapshots use this independent connection and therefore never queue
11632/// behind HTTP/WebSocket latency.
11633pub struct FileQuerySnapshotReader {
11634    path: String,
11635    conn: Option<Connection>,
11636}
11637
11638impl FileQuerySnapshotReader {
11639    #[must_use]
11640    pub fn new(path: impl Into<String>) -> Self {
11641        Self {
11642            path: path.into(),
11643            conn: None,
11644        }
11645    }
11646
11647    fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
11648        if self.conn.is_none() {
11649            let conn = Connection::open_with_flags(
11650                &self.path,
11651                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
11652            )
11653            .map_err(QueryReadFailure::from)?;
11654            conn.busy_timeout(std::time::Duration::from_millis(250))
11655                .map_err(QueryReadFailure::from)?;
11656            self.conn = Some(conn);
11657        }
11658        self.conn
11659            .as_ref()
11660            .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
11661    }
11662
11663    /// One atomic read over the shared primitive: every statement, coverage,
11664    /// catch-up, and delivery under one `SAVEPOINT` and one revision.
11665    pub fn snapshot_read(
11666        &mut self,
11667        request: &SnapshotReadRequest<'_>,
11668    ) -> Result<SnapshotRead, QueryReadFailure> {
11669        snapshot_read_connection(self.connection()?, request)
11670    }
11671
11672    /// The current durable local revision. A host can skip a read when it is
11673    /// unchanged; a corrupt or unreadable marker fails typed.
11674    pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
11675        Ok(self
11676            .snapshot_read(&SnapshotReadRequest::default())?
11677            .revision)
11678    }
11679
11680    /// One subscription's persisted catch-up state (see the §7.5 contract).
11681    pub fn subscription_catchup(
11682        &mut self,
11683        id: &str,
11684    ) -> Result<SubscriptionCatchup, QueryReadFailure> {
11685        let read = self.snapshot_read(&SnapshotReadRequest {
11686            subscriptions: vec![id.to_owned()],
11687            ..Default::default()
11688        })?;
11689        Ok(read
11690            .subscriptions
11691            .into_iter()
11692            .next()
11693            .expect("one requested catch-up"))
11694    }
11695
11696    /// One client commit's delivery status: pending, its retained outcome, or
11697    /// an explicit unknown (never held, or pruned by retention).
11698    pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
11699        let read = self.snapshot_read(&SnapshotReadRequest {
11700            commit_ids: vec![id.to_owned()],
11701            ..Default::default()
11702        })?;
11703        Ok(read
11704            .deliveries
11705            .into_iter()
11706            .next()
11707            .expect("one requested delivery"))
11708    }
11709
11710    /// Hosts report an owned failure, and the first success after it, to the
11711    /// owning core through [`SyncClient::record_query_read`] (§7.6).
11712    pub fn query_snapshot(
11713        &mut self,
11714        sql: &str,
11715        params: &[Value],
11716        coverage: &[WindowCoverage],
11717    ) -> Result<QuerySnapshot, QueryReadFailure> {
11718        snapshot_connection(self.connection()?, sql, params, coverage)
11719    }
11720}
11721
11722fn validate_authority_reads(
11723    schema: &ClientSchema,
11724    declarations: &[crate::api::AuthorityReadDeclaration],
11725) -> Result<(), String> {
11726    let mut seen = BTreeSet::new();
11727    for read in declarations {
11728        let table = schema
11729            .table(&read.table)
11730            .ok_or("client.authority_read_forbidden: authority table is not declared")?;
11731        let plain = |name: &str| {
11732            table.columns.iter().enumerate().any(|(i, c)| {
11733                c.name == name
11734                    && name != "authority_version"
11735                    && !name.starts_with("_sync")
11736                    && !table.encrypted_columns.iter().any(|e| e.index == i)
11737                    && matches!(
11738                        c.ty,
11739                        ssp2::segment::ColumnType::String
11740                            | ssp2::segment::ColumnType::Integer
11741                            | ssp2::segment::ColumnType::Float
11742                            | ssp2::segment::ColumnType::Boolean
11743                            | ssp2::segment::ColumnType::Json
11744                    )
11745            })
11746        };
11747        if !seen.insert(&read.table)
11748            || read.columns.is_empty()
11749            || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
11750            || !read.columns.contains(&table.primary_key)
11751            || !read.columns.iter().all(|c| plain(c))
11752            || read.scopes.is_empty()
11753            || !read.scopes.iter().all(|(v, values)| {
11754                table
11755                    .scope_column(v)
11756                    .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
11757                    && !values.is_empty()
11758                    && values.iter().all(|v| !v.is_empty() && v != "*")
11759            })
11760        {
11761            return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
11762        }
11763    }
11764    Ok(())
11765}
11766
11767// §7.4.2: every open and recreation checks before any persistent writes.
11768/// A marker read that hit a recognized SQLite storage failure keeps that
11769/// classification (BUSY/LOCKED stay retryable); only an unclassified read
11770/// failure is the invalid-marker `sync.local_corrupt`.
11771fn schema_marker_read_failure(error: rusqlite::Error) -> String {
11772    let failure = QueryReadFailure::from(error);
11773    if failure.code.is_some() {
11774        failure.to_string()
11775    } else {
11776        "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
11777    }
11778}
11779
11780/// §7.1: reject an invalid configured client limit before any storage is
11781/// opened or written. Zero would wedge the push queue and an out-of-range
11782/// value would differ per target; the TS core enforces the same bounds.
11783fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
11784    if limits.outcome_retention_max_entries == Some(0) {
11785        return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
11786    }
11787    if [
11788        limits.max_push_commits_per_request,
11789        limits.max_push_operations_per_request,
11790        limits.max_push_request_bytes,
11791    ]
11792    .into_iter()
11793    .flatten()
11794    .any(|value| value == 0 || value > u32::MAX as usize)
11795    {
11796        return Err(
11797            "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
11798        );
11799    }
11800    Ok(())
11801}
11802
11803fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
11804    let corrupt =
11805        || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
11806    let table_type: Option<String> = conn
11807        .query_row(
11808            "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
11809            [],
11810            |row| row.get(0),
11811        )
11812        .optional()
11813        .map_err(schema_marker_read_failure)?;
11814    let Some(table_type) = table_type else {
11815        return Ok(None);
11816    };
11817    if table_type != "table" {
11818        return Err(corrupt());
11819    }
11820    // §7.4.2: a duplicated marker cannot be resolved. `query_row` would take
11821    // whichever row SQLite returns first, and an older one would drive a
11822    // destructive reset over a newer replica.
11823    let mut statement = conn
11824        .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
11825        .map_err(schema_marker_read_failure)?;
11826    let mut rows = statement
11827        .query([LOCAL_SCHEMA_VERSION_KEY])
11828        .map_err(schema_marker_read_failure)?;
11829    let value: Option<String> = rows
11830        .next()
11831        .map_err(schema_marker_read_failure)?
11832        .map(|row| row.get(0))
11833        .transpose()
11834        .map_err(schema_marker_read_failure)?;
11835    if rows.next().map_err(schema_marker_read_failure)?.is_some() {
11836        return Err(corrupt());
11837    }
11838    let Some(value) = value else {
11839        let has_descriptor = conn
11840            .query_row(
11841                "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
11842                [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
11843                |_| Ok(()),
11844            )
11845            .optional()
11846            .map_err(schema_marker_read_failure)?
11847            .is_some();
11848        if has_descriptor {
11849            return Err(corrupt());
11850        }
11851        return Ok(None);
11852    };
11853    if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11854        || !value.bytes().all(|b| b.is_ascii_digit())
11855    {
11856        return Err(corrupt());
11857    }
11858    let version = value.parse::<i32>().map_err(|_| corrupt())?;
11859    if version > requested {
11860        return Err(
11861            "client.schema_downgrade: persisted local schema is newer than the requested schema"
11862                .to_owned(),
11863        );
11864    }
11865    Ok(Some(version))
11866}
11867
11868/// A file replica on a browser worker must recover a hot journal before any
11869/// statement reads it. `wasm_vfs` replaces the installed OPFS VFS's reserved
11870/// lock callback, so install it before the connection opens its file.
11871#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
11872fn prepare_wasm_replica(path: &str) -> Result<(), String> {
11873    if path.is_empty() || path == ":memory:" {
11874        return Ok(());
11875    }
11876    crate::wasm_vfs::disable_foreign_lock_check()
11877}
11878
11879#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
11880fn prepare_wasm_replica(_path: &str) -> Result<(), String> {
11881    Ok(())
11882}
11883
11884impl SyncClient {
11885    pub fn new_with_identity(
11886        client_id: Option<String>,
11887        schema_json: &Value,
11888        limits: ClientLimits,
11889    ) -> Result<Self, String> {
11890        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11891        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11892        Self::with_connection(resolved, schema_json, limits, conn)
11893    }
11894
11895    pub fn new(
11896        client_id: String,
11897        schema_json: &Value,
11898        limits: ClientLimits,
11899    ) -> Result<Self, String> {
11900        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11901        Self::with_connection(client_id, schema_json, limits, conn)
11902    }
11903
11904    /// Build a client backed by an on-disk SQLite database at `path` — the
11905    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
11906    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
11907    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
11908    /// of the command router's dependency set (the router only holds a path).
11909    pub fn open_path(
11910        client_id: String,
11911        schema_json: &Value,
11912        limits: ClientLimits,
11913        path: &str,
11914    ) -> Result<Self, String> {
11915        validate_client_limits(&limits)?;
11916        parse_schema_json(schema_json)?;
11917        prepare_wasm_replica(path)?;
11918        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11919        Self::with_connection(client_id, schema_json, limits, conn)
11920    }
11921
11922    /// Open a file replica and resolve its client id. Native targets require
11923    /// WAL for the independent snapshot reader; `wasm32-unknown-unknown`
11924    /// accepts the sqlite-wasm worker journal instead.
11925    pub fn open_path_with_identity(
11926        client_id: Option<String>,
11927        schema_json: &Value,
11928        limits: ClientLimits,
11929        path: &str,
11930    ) -> Result<Self, String> {
11931        Self::open_path_with_identity_journal(
11932            client_id,
11933            schema_json,
11934            limits,
11935            path,
11936            !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
11937        )
11938    }
11939
11940    fn open_path_with_identity_journal(
11941        client_id: Option<String>,
11942        schema_json: &Value,
11943        limits: ClientLimits,
11944        path: &str,
11945        require_wal: bool,
11946    ) -> Result<Self, String> {
11947        validate_client_limits(&limits)?;
11948        // §7.4.2: an invalid requested version is refused before the replica is
11949        // even created, so a rejected constructor leaves no file behind.
11950        parse_schema_json(schema_json)?;
11951        prepare_wasm_replica(path)?;
11952        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11953        // A file-backed native client uses an independent read connection for
11954        // latency-critical snapshots. WAL is SQLite's intended reader/writer
11955        // concurrency mode: a view read never holds a rollback-journal lock
11956        // that delays the client's next commit. The busy timeout must precede
11957        // this replica's own startup transaction; WAL is applied only after
11958        // startup succeeded, because a refused open must leave the replica's
11959        // journal mode and contents untouched (§7.4.2).
11960        conn.busy_timeout(std::time::Duration::from_millis(250))
11961            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
11962        // Durability is the caller's contract here, not the VFS default: a
11963        // rollback-journal commit needs FULL before it writes the database
11964        // pages, or a killed worker can leave a half-written image behind.
11965        // WAL below inherits the same setting.
11966        conn.pragma_update(None, "synchronous", "FULL")
11967            .map_err(|error| format!("configure db {path:?} synchronous: {error}"))?;
11968        let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
11969        // A browser worker's sqlite-wasm VFS has no WAL mode, and the wasm
11970        // host has no independent snapshot reader. The WAL requirement exists
11971        // for native file replicas, where it is the reader/writer boundary.
11972        if require_wal {
11973            let journal_mode: String = client
11974                .conn
11975                .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
11976                .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11977            if journal_mode != "wal" {
11978                return Err(
11979                    "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
11980                );
11981            }
11982        }
11983        Ok(client)
11984    }
11985
11986    /// Delete a file replica at `path`, including its `-journal`, `-wal`, and
11987    /// `-shm` sidecars, through the VFS that owns it. Use this after
11988    /// `client.storage_corrupt`: an OPFS SAH pool stores those files under
11989    /// opaque names, so deleting the pool directory behind SQLite bypasses the
11990    /// pool's slot accounting.
11991    ///
11992    /// Close the client and every [`FileQuerySnapshotReader`] on `path` first;
11993    /// a VFS cannot delete a file another handle still owns. The next open
11994    /// creates a fresh replica (new client id) and re-bootstraps from the
11995    /// server. A memory replica has nothing to delete and fails with
11996    /// `sync.invalid_request`.
11997    pub fn reset_replica(path: &str) -> Result<(), String> {
11998        if path.is_empty() || path == ":memory:" {
11999            return Err("sync.invalid_request: a memory replica has nothing to reset".to_owned());
12000        }
12001        // SAFETY: a null VFS name asks SQLite for its process-wide default VFS,
12002        // the same one `Connection::open(path)` above selects. `xDelete` is
12003        // that VFS's own function pointer, and `c_name` outlives the call.
12004        let vfs = unsafe { rusqlite::ffi::sqlite3_vfs_find(std::ptr::null()) };
12005        if vfs.is_null() {
12006            return Err("client.storage_io: no SQLite VFS is registered".to_owned());
12007        }
12008        let delete = unsafe { (*vfs).xDelete }
12009            .ok_or_else(|| "client.storage_io: the SQLite VFS cannot delete files".to_owned())?;
12010        for suffix in ["-wal", "-shm", "-journal", ""] {
12011            let file_name = format!("{path}{suffix}");
12012            let c_name = std::ffi::CString::new(file_name.as_str()).map_err(|_| {
12013                format!("sync.invalid_request: replica path {file_name:?} contains a NUL byte")
12014            })?;
12015            // SAFETY: `vfs` and its `xDelete` pointer come from the live
12016            // SQLite library; `c_name` is a valid NUL-terminated path.
12017            let code = unsafe { delete(vfs, c_name.as_ptr(), 1) };
12018            if code != rusqlite::ffi::SQLITE_OK {
12019                return Err(format!(
12020                    "client.storage_io: deleting replica file {file_name:?} failed with SQLite code {code}"
12021                ));
12022            }
12023        }
12024        Ok(())
12025    }
12026
12027    #[must_use]
12028    pub fn client_id(&self) -> &str {
12029        &self.client_id
12030    }
12031
12032    /// Rotate the caller's client id (§1.5) and return the new one. Use it
12033    /// after a `sync.invalid_client_id` refusal (`recommendedAction:
12034    /// resetClientId`): the next round carries the new id. A live realtime
12035    /// socket is closed so the next connection registers with the new id.
12036    /// The replica and its outbox stay in place; wipe them yourself when the
12037    /// stored data belongs to the account that signed out.
12038    pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> String {
12039        self.disconnect_realtime(transport);
12040        let client_id = uuid::Uuid::new_v4().to_string();
12041        self.client_id.clone_from(&client_id);
12042        self.set_meta(CLIENT_ID_KEY, &client_id);
12043        self.round_failure = None;
12044        client_id
12045    }
12046
12047    /// Build a client over a caller-supplied rusqlite connection — the seam a
12048    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
12049    /// with an on-disk database (`Connection::open(path)`) rather than the
12050    /// default `:memory:`. The connection MUST be fresh (no pre-existing
12051    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
12052    /// the same file across process restarts reuses the persisted rows.
12053    pub fn with_connection(
12054        client_id: String,
12055        schema_json: &Value,
12056        limits: ClientLimits,
12057        conn: Connection,
12058    ) -> Result<Self, String> {
12059        Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
12060    }
12061
12062    /// [`Self::with_connection`] for `open_path_with_identity`: no requested id
12063    /// means a durable replica's persisted `clientId` wins over a fresh one.
12064    fn with_connection_identity(
12065        client_id: Option<String>,
12066        schema_json: &Value,
12067        limits: ClientLimits,
12068        conn: Connection,
12069    ) -> Result<Self, String> {
12070        validate_client_limits(&limits)?;
12071        let schema = parse_schema_json(schema_json)?;
12072        validate_authority_reads(&schema, &limits.authority_reads)?;
12073        // RFC 0005 D8: resolve the previous-version config before the opening
12074        // reset runs — the capture happens inside it. Bad bounds fail loud.
12075        let previous_version = match limits.previous_version_context {
12076            Some(config) => {
12077                config.validate()?;
12078                Some(config)
12079            }
12080            None => None,
12081        };
12082        let mut client = SyncClient {
12083            #[cfg(feature = "bench-internals")]
12084            benchmark_phases: Recorder::default(),
12085            conn,
12086            schema,
12087            client_id: client_id
12088                .clone()
12089                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
12090            limits,
12091            subs: Vec::new(),
12092            outbox: Vec::new(),
12093            failed_commits: Vec::new(),
12094            retain_failed_commits: false,
12095            conflicts: Vec::new(),
12096            rejections: Vec::new(),
12097            schema_floor: None,
12098            lease_state: None,
12099            stopped: false,
12100            upgrading: false,
12101            sync_needed: false,
12102            realtime_policy: RealtimePolicy::Optional,
12103            realtime_state: RealtimeState::Disconnected,
12104            realtime_reason_code: None,
12105            realtime_retry_delay_ms: None,
12106            presence: HashMap::new(),
12107            now_ms: None,
12108            encryption: crate::values::EncryptionConfig::default(),
12109            security_preflight: false,
12110            transport_enabled: true,
12111            previous_version,
12112            insert_sql: RefCell::new(HashMap::new()),
12113            overlay_dirty: OverlayDirty::clean(),
12114            storage_failure: RefCell::new(None),
12115            #[cfg(test)]
12116            overlay_rebuild_count: Cell::new(0),
12117            #[cfg(test)]
12118            acknowledged_replay_count: Cell::new(0),
12119            #[cfg(test)]
12120            outcome_prune_count: Cell::new(0),
12121            progress: ProgressObserver::default(),
12122            change_queue: VecDeque::new(),
12123            sync_intent_queue: VecDeque::new(),
12124            retry_delay_ms: 250,
12125            round_retry_delay_ms: None,
12126            round_failure: None,
12127            active_round: None,
12128            last_round: None,
12129            last_change: None,
12130            query_failures: Vec::new(),
12131            coalesced: None,
12132            coalesced_failure: None,
12133        };
12134        // The row write path leans on the prepared-statement cache (two
12135        // insert statements per synced table, plus the bookkeeping
12136        // statements); size it so a multi-table schema never thrashes.
12137        client
12138            .conn
12139            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
12140        // §7.4.2: one write transaction (SQLite `BEGIN IMMEDIATE`, a reserved
12141        // writer) covers the marker read, the identity check, and every
12142        // bookkeeping/schema write, so a concurrent open that upgraded the
12143        // replica cannot interleave between the guard and the writes, and a
12144        // refusal leaves the replica unchanged.
12145        client
12146            .conn
12147            .execute_batch("BEGIN IMMEDIATE")
12148            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12149        let startup = (|| -> Result<(), String> {
12150            // The marker guard runs inside the write transaction, so the version
12151            // it validates is the version every write below commits under.
12152            let marker = read_local_schema_version(&client.conn, client.schema.version)?;
12153            // New app indexes may reference columns that only exist after a
12154            // version-bump reset.
12155            client.create_bookkeeping_tables()?;
12156            match client.get_meta(CLIENT_ID_KEY) {
12157                Some(existing) => match client_id {
12158                    Some(requested) if existing != requested => {
12159                        return Err(format!(
12160                            "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
12161                        ))
12162                    }
12163                    _ => client.client_id = existing,
12164                },
12165                None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
12166            }
12167            client.restore_persisted_state()?;
12168            client.failed_commits = client.load_failed_commits()?;
12169            match marker {
12170                None => {
12171                    client.create_synced_tables()?;
12172                    client.persist_schema_version()?;
12173                    client.save_subscription_scope_schema();
12174                }
12175                Some(version) if version == client.schema.version => {
12176                    client.create_synced_tables()?;
12177                    // RFC 0005 D1: the same-version open backfills the descriptor
12178                    // for a database first opened by an unaware binary, so the
12179                    // NEXT bump can capture without needing a schema bump first.
12180                    set_local_schema_descriptor(&client.conn, &client.schema)
12181                        .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12182                    client.save_subscription_scope_schema();
12183                }
12184                Some(_) => client.run_schema_reset()?,
12185            }
12186            // RFC 0005 D9: the reset sweep cannot see a container whose capture
12187            // committed in its OWN file while the replica's savepoint never
12188            // released — a crash at that seam leaves a container beside a matching
12189            // OLD marker, and the next open at that version runs no reset. Discard
12190            // it before anything can read it. No-op when no container exists.
12191            client.reconcile_previous_version_at_boot()?;
12192            client.clear_satisfied_persisted_schema_floor();
12193            client.prune_unknown_subscriptions(false)?;
12194            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
12195            // the full startup/reset reconciliation boundary, inside this open
12196            // transaction and after the schema and identity guards, so a commit
12197            // an earlier version accepted is dropped with durable rejection
12198            // evidence before any replay. A table the current schema removed
12199            // keeps the §7.4.4 send-time classification, and an unresolved
12200            // encryption key stays with the send seam because hosts configure
12201            // keys after open.
12202            client.drop_unencodable_outbox(false, None)?;
12203            if marker == Some(client.schema.version)
12204                && (!client.outbox.is_empty()
12205                    || !client.failed_commits.is_empty()
12206                    || client.has_acknowledged_rows())
12207            {
12208                // Reconstruct the visible optimistic overlay from the durable
12209                // base plus outbox instead of trusting a process-interrupted
12210                // mirror.
12211                client.overlay_dirty.set(true);
12212                client.rebuild_overlay()?;
12213            }
12214            // Every persisted active subscription needs one catch-up pull on
12215            // open: realtime only covers changes after connection, while an
12216            // idempotent setWindow correctly creates no fresh command effect.
12217            // Pending outbox work has the same restart requirement. The core
12218            // owns this intent so native hosts never poll or require an
12219            // application-issued sync().
12220            client.enqueue_startup_sync_if_needed()?;
12221            Ok(())
12222        })();
12223        match startup {
12224            Ok(()) => match client.conn.execute_batch("COMMIT") {
12225                Ok(()) => Ok(client),
12226                Err(error) => {
12227                    let failure = Self::sqlite_failure(&client.storage_failure, error);
12228                    if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12229                        // Keep a failed rollback's typed storage classification.
12230                        let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12231                    }
12232                    Err(failure)
12233                }
12234            },
12235            Err(error) => {
12236                if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12237                    // Keep a failed rollback's typed storage classification.
12238                    let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12239                }
12240                Err(error)
12241            }
12242        }
12243    }
12244
12245    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
12246    /// against this instead of system time (conformance virtual clock).
12247    pub fn set_now_ms(&mut self, now_ms: i64) {
12248        self.now_ms = Some(now_ms);
12249    }
12250
12251    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
12252    /// The command router parses these from the `create` command's
12253    /// `encryption` config (keys as `{$bytes: hex}`).
12254    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
12255        self.encryption = encryption;
12256    }
12257
12258    #[must_use]
12259    pub fn security_lifecycle(&self) -> &'static str {
12260        if self.security_preflight {
12261            "preflight"
12262        } else {
12263            "active"
12264        }
12265    }
12266
12267    #[must_use]
12268    pub fn security_preflight(&self) -> bool {
12269        self.security_preflight
12270    }
12271
12272    /// Host-owned, non-persisted network gate. Local operations remain available.
12273    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
12274        if self.transport_enabled != enabled {
12275            self.transport_enabled = enabled;
12276            self.sync_intent_queue.clear();
12277            self.sync_intent_queue
12278                .push_back(if enabled && !self.security_preflight {
12279                    SyncIntent::Interactive
12280                } else {
12281                    SyncIntent::None
12282                });
12283        }
12284        // A captured reply keeps its normal apply/revocation semantics.
12285        if !enabled && self.active_round.is_none() {
12286            self.disconnect_realtime(transport);
12287        }
12288    }
12289
12290    pub fn transport_enabled(&self) -> bool {
12291        self.transport_enabled
12292    }
12293
12294    /// Quarantine this replica: enter the fail-closed gate, release all
12295    /// core-owned key material, and record the gate in the database so
12296    /// reopening re-enters preflight until `activate_security` clears it.
12297    ///
12298    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
12299    /// which must NOT leave that durable mark.
12300    pub fn begin_security_preflight(&mut self) {
12301        self.seal_security_on_teardown();
12302        // Persist the quarantine so it survives handle teardown and restart:
12303        // reopening this replica re-enters preflight until activation clears it.
12304        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
12305    }
12306
12307    /// Release core-owned key material as a host tears the client down,
12308    /// WITHOUT recording a quarantine.
12309    ///
12310    /// Shutting an activated client down is not a quarantine event. Persisting
12311    /// the gate here marks every cleanly closed replica as pending, and the
12312    /// next plain `create` is then refused permanently: the reopen path
12313    /// restores the flag from the marker and the create guard rejects it. The
12314    /// in-memory flag still closes the gate for anything still holding this
12315    /// instance, which is all a teardown barrier needs.
12316    pub fn seal_security_on_teardown(&mut self) {
12317        self.cancel_sync_round();
12318        self.security_preflight = true;
12319        self.encryption = crate::values::EncryptionConfig::default();
12320        self.sync_intent_queue.clear();
12321    }
12322
12323    /// Install the post-authentication keyring and release the host loop.
12324    pub fn activate_security(
12325        &mut self,
12326        encryption: crate::values::EncryptionConfig,
12327    ) -> Result<(), String> {
12328        if !self.security_preflight {
12329            return Err(
12330                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
12331            );
12332        }
12333        self.enqueue_startup_sync_if_needed()?;
12334        self.encryption = encryption;
12335        self.security_preflight = false;
12336        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
12337        Ok(())
12338    }
12339
12340    fn clock_now_ms(&self) -> i64 {
12341        self.now_ms.unwrap_or_else(crate::wall_clock_ms)
12342    }
12343
12344    fn create_bookkeeping_tables(&self) -> Result<(), String> {
12345        // Durable client bookkeeping (outbox + subscription + meta).
12346        self.conn
12347            .execute_batch(
12348                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
12349                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
12350                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
12351                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
12352                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
12353                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
12354                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
12355                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
12356                   PRIMARY KEY(commit_id, idx));
12357                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
12358                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
12359                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
12360                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
12361                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
12362                   client_commit_id TEXT NOT NULL UNIQUE,
12363                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
12364                   recorded_at_ms INTEGER NOT NULL,
12365                   results_json TEXT NOT NULL,
12366                   operations_json TEXT,
12367                   resolution TEXT NOT NULL DEFAULT 'active'
12368                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
12369                   resolved_at_ms INTEGER,
12370                   replacement_client_commit_id TEXT);
12371                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
12372                   ON _syncular_commit_outcomes(resolution, seq);
12373                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
12374                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
12375                 CREATE TABLE IF NOT EXISTS _syncular_meta (
12376                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
12377                 CREATE TABLE IF NOT EXISTS _syncular_windows (
12378                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
12379                   PRIMARY KEY (base, unit));
12380                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
12381                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
12382                   effective_scopes TEXT NOT NULL);
12383                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
12384                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
12385                   PRIMARY KEY(commit_id, blob_id));
12386                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
12387                   ON _syncular_blob_commit_refs(blob_id);",
12388            )
12389            .map_err(|e| e.to_string())?;
12390        // Migrate an outcome journal created before failed aggregate
12391        // envelopes were retained. Historical rows intentionally stay NULL.
12392        let _ = self
12393            .conn
12394            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
12395        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
12396            self.set_meta(LOCAL_REVISION_KEY, "0");
12397        }
12398        // §5.9.7 blob cache. Mutable upload state stays off the body row so
12399        // completing a large upload does not rewrite its BLOB record.
12400        if self.schema_has_blobs() {
12401            let exists = self
12402                .conn
12403                .query_row(
12404                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
12405                    [],
12406                    |row| row.get::<_, bool>(0),
12407                )
12408                .map_err(|e| e.to_string())?;
12409            if exists {
12410                let mut statement = self
12411                    .conn
12412                    .prepare("PRAGMA table_info(_syncular_blobs)")
12413                    .map_err(|e| e.to_string())?;
12414                let mut columns = statement
12415                    .query_map([], |row| row.get::<_, String>(1))
12416                    .map_err(|e| e.to_string())?
12417                    .collect::<Result<Vec<_>, _>>()
12418                    .map_err(|e| e.to_string())?;
12419                columns.sort();
12420                let mut expected = vec![
12421                    "blob_id",
12422                    "byte_length",
12423                    "bytes",
12424                    "created_at_ms",
12425                    "media_type",
12426                ];
12427                expected.sort_unstable();
12428                if columns != expected {
12429                    return Err(
12430                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
12431                    );
12432                }
12433            } else {
12434                self.conn
12435                    .execute_batch(
12436                        "CREATE TABLE _syncular_blobs(
12437                           blob_id TEXT PRIMARY KEY,
12438                           bytes BLOB NOT NULL,
12439                           byte_length INTEGER NOT NULL,
12440                           media_type TEXT,
12441                           created_at_ms INTEGER NOT NULL);",
12442                    )
12443                    .map_err(|e| e.to_string())?;
12444            }
12445            self.conn
12446                .execute_batch(
12447                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
12448                       blob_id TEXT PRIMARY KEY,
12449                       media_type TEXT,
12450                       created_at_ms INTEGER NOT NULL);",
12451                )
12452                .map_err(|e| e.to_string())?;
12453        }
12454        Ok(())
12455    }
12456
12457    /// True iff any synced table declares a `blob_ref` column (§5.9).
12458    fn schema_has_blobs(&self) -> bool {
12459        self.schema
12460            .tables
12461            .iter()
12462            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
12463    }
12464
12465    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
12466
12467    fn get_meta(&self, key: &str) -> Option<String> {
12468        meta_get(&self.conn, key)
12469    }
12470
12471    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
12472        self.conn
12473            .query_row(
12474                "SELECT value FROM _syncular_meta WHERE key = ?1",
12475                rusqlite::params![key],
12476                |row| row.get::<_, String>(0),
12477            )
12478            .optional()
12479            .map_err(|_| {
12480                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
12481            })
12482    }
12483
12484    // The caller holds the startup/reset transaction. Either both schema
12485    // records persist or the enclosing operation fails and rolls back.
12486    fn persist_schema_version(&self) -> Result<(), String> {
12487        self.conn
12488            .execute(
12489                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12490                rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
12491            )
12492            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12493        set_local_schema_descriptor(&self.conn, &self.schema)
12494            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12495    }
12496
12497    pub(crate) fn set_meta(&self, key: &str, value: &str) {
12498        meta_set(&self.conn, key, value);
12499    }
12500
12501    fn delete_meta(&self, key: &str) {
12502        meta_delete(&self.conn, key);
12503    }
12504
12505    /// §7.1: the durable outbox in FIFO order, in schema-agnostic local form.
12506    fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
12507        let mut stmt = self
12508            .conn
12509            .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
12510            .map_err(|error| error.to_string())?;
12511        let rows = stmt
12512            .query_map([], |row| {
12513                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
12514            })
12515            .map_err(|error| error.to_string())?;
12516        let mut commits = Vec::new();
12517        for row in rows {
12518            let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
12519            let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
12520                format!("invalid persisted outbox {client_commit_id:?}: {error}")
12521            })?;
12522            let mut ops = Vec::with_capacity(entries.len());
12523            for entry in entries {
12524                let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
12525                ops.push(OutboxOp {
12526                    upsert: op == "upsert",
12527                    table: entry
12528                        .get("table")
12529                        .and_then(Value::as_str)
12530                        .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
12531                        .to_owned(),
12532                    row_id: entry
12533                        .get("rowId")
12534                        .and_then(Value::as_str)
12535                        .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
12536                        .to_owned(),
12537                    base_version: entry.get("baseVersion").and_then(Value::as_i64),
12538                    // A legacy entry's `changedFields` key is ignored:
12539                    // the values map is the presence set (§6.1).
12540                    values: entry.get("values").and_then(Value::as_object).cloned(),
12541                });
12542            }
12543            commits.push(OutboxCommit {
12544                client_commit_id,
12545                ops,
12546            });
12547        }
12548        Ok(commits)
12549    }
12550
12551    fn restore_persisted_state(&mut self) -> Result<(), String> {
12552        self.subs = {
12553            let mut stmt = self
12554                .conn
12555                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
12556                .map_err(|error| error.to_string())?;
12557            let rows = stmt
12558                .query_map([], |row| {
12559                    Ok((
12560                        row.get::<_, String>(0)?,
12561                        row.get::<_, String>(1)?,
12562                        row.get::<_, String>(2)?,
12563                    ))
12564                })
12565                .map_err(|error| error.to_string())?;
12566            let mut subscriptions = Vec::new();
12567            for row in rows {
12568                let (id, table, raw) = row.map_err(|error| error.to_string())?;
12569                let state: Value = serde_json::from_str(&raw)
12570                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
12571                let requested = json_to_scope_map(
12572                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
12573                )?;
12574                let effective = state
12575                    .get("effectiveScopes")
12576                    .filter(|value| !value.is_null())
12577                    .map(json_to_scope_map)
12578                    .transpose()?;
12579                subscriptions.push(Subscription {
12580                    id,
12581                    table,
12582                    requested,
12583                    params: state
12584                        .get("params")
12585                        .and_then(Value::as_str)
12586                        .map(str::to_owned),
12587                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
12588                    bootstrap_state: state
12589                        .get("bootstrapState")
12590                        .and_then(Value::as_str)
12591                        .map(str::to_owned),
12592                    state: SubState::parse(
12593                        state
12594                            .get("status")
12595                            .and_then(Value::as_str)
12596                            .unwrap_or("active"),
12597                    ),
12598                    reason_code: state
12599                        .get("reasonCode")
12600                        .and_then(Value::as_str)
12601                        .map(str::to_owned),
12602                    effective,
12603                    synced_once: state
12604                        .get("syncedOnce")
12605                        .and_then(Value::as_bool)
12606                        .unwrap_or(false),
12607                });
12608            }
12609            subscriptions
12610        };
12611
12612        self.outbox = self.load_outbox()?;
12613
12614        self.prune_commit_outcomes()?;
12615        let active = self.commit_outcomes(CommitOutcomeQuery {
12616            active_only: true,
12617            ..CommitOutcomeQuery::default()
12618        })?;
12619        self.conflicts = active
12620            .iter()
12621            .flat_map(|outcome| outcome.results.iter())
12622            .filter_map(|result| match result {
12623                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
12624                _ => None,
12625            })
12626            .collect();
12627        self.rejections = active
12628            .iter()
12629            .flat_map(|outcome| outcome.results.iter())
12630            .filter_map(|result| match result {
12631                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
12632                _ => None,
12633            })
12634            .collect();
12635
12636        self.lease_state = self
12637            .get_meta(LEASE_STATE_KEY)
12638            .map(|raw| serde_json::from_str(&raw))
12639            .transpose()
12640            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
12641        self.schema_floor = self
12642            .get_meta(SCHEMA_FLOOR_KEY)
12643            .map(|raw| serde_json::from_str(&raw))
12644            .transpose()
12645            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
12646        self.stopped = self.schema_floor.is_some();
12647        // A replica left in unactivated preflight reopens gated: the quarantine
12648        // decision persists with the data across handle teardown and restart.
12649        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
12650            self.security_preflight = true;
12651        }
12652        Ok(())
12653    }
12654
12655    fn save_subscription_scope_schema(&self) {
12656        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
12657            .schema
12658            .tables
12659            .iter()
12660            .map(|table| {
12661                (
12662                    table.name.clone(),
12663                    table
12664                        .scope_variables
12665                        .iter()
12666                        .map(|scope| {
12667                            (
12668                                scope.variable.clone(),
12669                                [scope.prefix.clone(), scope.column.clone()],
12670                            )
12671                        })
12672                        .collect(),
12673                )
12674            })
12675            .collect();
12676        self.set_meta(
12677            "subscriptionScopeSchema",
12678            &serde_json::to_string(&declarations).expect("scope schema serialization"),
12679        );
12680    }
12681
12682    /// Remove registrations with incompatible table/key/prefix/column meaning.
12683    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
12684        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
12685            self.get_meta("subscriptionScopeSchema")
12686                .map(|raw| serde_json::from_str(&raw))
12687                .transpose()
12688                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
12689        } else {
12690            None
12691        };
12692        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
12693            let Some(table) = self.schema.table(table_name) else {
12694                return false;
12695            };
12696            if requested
12697                .iter()
12698                .any(|(variable, _)| table.scope_column(variable).is_none())
12699            {
12700                return false;
12701            }
12702            if !bump {
12703                return true;
12704            }
12705            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
12706                return false;
12707            };
12708            let variables: BTreeSet<_> = if requested.is_empty() {
12709                old.keys()
12710                    .cloned()
12711                    .chain(
12712                        table
12713                            .scope_variables
12714                            .iter()
12715                            .map(|scope| scope.variable.clone()),
12716                    )
12717                    .collect()
12718            } else {
12719                requested
12720                    .iter()
12721                    .map(|(variable, _)| variable.clone())
12722                    .collect()
12723            };
12724            variables.iter().all(|variable| {
12725                let current = table
12726                    .scope_variables
12727                    .iter()
12728                    .find(|scope| &scope.variable == variable);
12729                match (old.get(variable), current) {
12730                    (Some(old), Some(current)) => {
12731                        old == &[current.prefix.clone(), current.column.clone()]
12732                    }
12733                    _ => false,
12734                }
12735            })
12736        };
12737        let mut stale_ids: BTreeSet<_> = self
12738            .subs
12739            .iter()
12740            .filter(|sub| !compatible(&sub.table, &sub.requested))
12741            .map(|sub| sub.id.clone())
12742            .collect();
12743        for (id, table, effective) in self.load_pending_evictions()? {
12744            if !compatible(&table, &effective) {
12745                stale_ids.insert(id);
12746            }
12747        }
12748        for id in &stale_ids {
12749            for sql in [
12750                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
12751                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
12752                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
12753            ] {
12754                self.conn
12755                    .execute(sql, rusqlite::params![id])
12756                    .map_err(|error| error.to_string())?;
12757            }
12758        }
12759        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
12760        Ok(())
12761    }
12762
12763    #[must_use]
12764    pub fn local_revision(&self) -> u64 {
12765        self.get_meta(LOCAL_REVISION_KEY)
12766            .and_then(|value| value.parse().ok())
12767            .unwrap_or(0)
12768    }
12769
12770    /// §7.5: resolve a snapshot read on the mutable owner connection through
12771    /// the same primitive the read-only sidecar uses, so the command surface
12772    /// and the file sidecar return one revision, rows, coverage, catch-up, and
12773    /// delivery shape. The structured [`QueryReadFailure`] is preserved; an
12774    /// `owner` records its failure (and its next success) in diagnostics.
12775    pub fn snapshot_read(
12776        &mut self,
12777        request: &SnapshotReadRequest<'_>,
12778        owner: Option<QueryOwner<'_>>,
12779    ) -> Result<SnapshotRead, QueryReadFailure> {
12780        let result = snapshot_read_connection(&self.conn, request);
12781        if let Some(owner) = owner {
12782            self.record_query_read(owner, result.as_ref().err());
12783        }
12784        result
12785    }
12786
12787    #[must_use]
12788    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
12789        SyncStatusSnapshot {
12790            current_schema_version: self.schema.version,
12791            outbox: self.outbox.len(),
12792            upgrading: self.upgrading,
12793            lease_state: self.lease_state.clone(),
12794            schema_floor: self.schema_floor.clone(),
12795            sync_needed: self.sync_needed,
12796            previous_version_context: self.previous_version_status(),
12797        }
12798    }
12799
12800    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
12801    /// rollback precondition), read without creating the file.
12802    fn previous_version_status(&self) -> PreviousVersionStatus {
12803        let Some(replica_path) = self.previous_version_replica_path() else {
12804            return PreviousVersionStatus {
12805                present: false,
12806                created_at_ms: None,
12807            };
12808        };
12809        match crate::previous_version::read_previous_version_record(&replica_path) {
12810            Ok(Some(record)) => PreviousVersionStatus {
12811                present: true,
12812                created_at_ms: Some(record.created_at_ms),
12813            },
12814            _ => PreviousVersionStatus {
12815                present: std::path::Path::new(
12816                    &crate::previous_version::previous_version_container_path(&replica_path),
12817                )
12818                .exists(),
12819                created_at_ms: None,
12820            },
12821        }
12822    }
12823
12824    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
12825    /// is always `previousVersion`; the container never makes
12826    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
12827    /// revocation, coverage completion and the TTL DISCARD the container and
12828    /// close the read with their own reason.
12829    pub fn previous_version_snapshot(
12830        &self,
12831        spec: &PreviousVersionReadSpec,
12832    ) -> Result<PreviousVersionSnapshot, String> {
12833        let now_ms = self.clock_now_ms();
12834        crate::previous_version::previous_version_snapshot(
12835            &self.conn,
12836            self.previous_version_replica_path().as_deref(),
12837            self.previous_version.as_ref(),
12838            PreviousVersionLifecycle {
12839                lease_inactive: self.previous_version_lease_inactive(now_ms),
12840                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
12841                coverage_complete: self.previous_version_coverage_complete(),
12842            },
12843            now_ms,
12844            self.schema.version,
12845            spec,
12846        )
12847    }
12848
12849    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
12850    #[must_use]
12851    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
12852        crate::previous_version::stored_previous_version_audit(&self.conn)
12853    }
12854
12855    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
12856    /// and both metadata records, and report whether anything was present.
12857    /// Idempotent: absence is a successful no-op.
12858    pub fn previous_version_discard(
12859        &self,
12860    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
12861        self.drop_previous_version()
12862    }
12863
12864    /// The module's discard has no failure path (it removes the file even when
12865    /// the container cannot be opened), so the only error a caller could ever
12866    /// see is a bug; report absence rather than claiming a discard happened.
12867    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
12868        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
12869            present: false,
12870            discarded: false,
12871        };
12872        let Some(replica_path) = self.previous_version_replica_path() else {
12873            return absent;
12874        };
12875        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
12876            .unwrap_or(absent)
12877    }
12878
12879    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
12880    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
12881        let Some(lease) = &self.lease_state else {
12882            return false;
12883        };
12884        if lease.error_code.is_some() {
12885            return true;
12886        }
12887        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
12888    }
12889
12890    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
12891    /// has `cursor >= 0` and no resume token. An empty active set is not
12892    /// completion (mirrors the TS predicate exactly).
12893    fn previous_version_coverage_complete(&self) -> bool {
12894        let active = self
12895            .subs
12896            .iter()
12897            .filter(|sub| sub.state == SubState::Active)
12898            .collect::<Vec<_>>();
12899        if active.is_empty() {
12900            return false;
12901        }
12902        active
12903            .iter()
12904            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
12905    }
12906
12907    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
12908    /// apply. A container that no longer has a reason to exist is discarded so
12909    /// the rows do not outlive the window they were kept for.
12910    fn previous_version_lifetime_check(&self) {
12911        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
12912            return;
12913        };
12914        let now_ms = self.clock_now_ms();
12915        if self.previous_version_lease_inactive(now_ms) {
12916            let _ = self.drop_previous_version();
12917            return;
12918        }
12919        let Some(replica_path) = self.previous_version_replica_path() else {
12920            return;
12921        };
12922        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
12923        else {
12924            return;
12925        };
12926        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
12927            let _ = self.drop_previous_version();
12928            return;
12929        }
12930        if now_ms - record.created_at_ms > config.max_age_ms {
12931            let _ = self.drop_previous_version();
12932            return;
12933        }
12934        if self.previous_version_coverage_complete() {
12935            let _ = self.drop_previous_version();
12936        }
12937    }
12938
12939    pub fn diagnostics_snapshot(
12940        &self,
12941        request: &ClientDiagnosticsRequest,
12942    ) -> Result<ClientDiagnosticsSnapshot, String> {
12943        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12944            return Err(format!(
12945                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12946            ));
12947        }
12948        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
12949        for sub in &self.subs {
12950            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
12951            let complete =
12952                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
12953            let state = match sub.state {
12954                SubState::Revoked => "revoked",
12955                SubState::Failed => "failed",
12956                SubState::Active if reset => "reset",
12957                SubState::Active if complete => "complete",
12958                SubState::Active => "bootstrapping",
12959            };
12960            subscriptions.insert(
12961                sub.id.clone(),
12962                DiagnosticSubscription {
12963                    id: sub.id.clone(),
12964                    table: sub.table.clone(),
12965                    state: state.to_owned(),
12966                    complete,
12967                    cursor: Some(sub.cursor),
12968                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
12969                },
12970            );
12971        }
12972        for expected in &request.expected_subscriptions {
12973            if expected.id.is_empty() || expected.table.is_empty() {
12974                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
12975            }
12976            if subscriptions
12977                .get(&expected.id)
12978                .is_some_and(|registered| registered.table != expected.table)
12979            {
12980                subscriptions.insert(
12981                    expected.id.clone(),
12982                    DiagnosticSubscription {
12983                        id: expected.id.clone(),
12984                        table: expected.table.clone(),
12985                        state: "failed".to_owned(),
12986                        complete: false,
12987                        cursor: None,
12988                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
12989                    },
12990                );
12991            } else {
12992                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
12993                    DiagnosticSubscription {
12994                        id: expected.id.clone(),
12995                        table: expected.table.clone(),
12996                        state: "unregistered".to_owned(),
12997                        complete: false,
12998                        cursor: None,
12999                        reason_code: None,
13000                    }
13001                });
13002            }
13003        }
13004        let mut ordered_subscriptions = Vec::new();
13005        let mut included = BTreeSet::new();
13006        for expected in &request.expected_subscriptions {
13007            if included.insert(expected.id.clone()) {
13008                if let Some(subscription) = subscriptions.get(&expected.id) {
13009                    ordered_subscriptions.push(subscription.clone());
13010                }
13011            }
13012        }
13013        for (id, subscription) in &subscriptions {
13014            if included.insert(id.clone()) {
13015                ordered_subscriptions.push(subscription.clone());
13016            }
13017        }
13018        let subscriptions_truncated =
13019            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
13020        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
13021        let captured_at_ms = self.clock_now_ms();
13022        let lease = if let Some(error_code) = self
13023            .lease_state
13024            .as_ref()
13025            .and_then(|state| state.error_code.clone())
13026        {
13027            ClientDiagnosticsLease {
13028                state: "stopped".to_owned(),
13029                expires_at_ms: self
13030                    .lease_state
13031                    .as_ref()
13032                    .and_then(|state| state.expires_at_ms),
13033                error_code: Some(Self::diagnostic_code(&error_code)),
13034            }
13035        } else if let Some(expires_at_ms) = self
13036            .lease_state
13037            .as_ref()
13038            .and_then(|state| state.expires_at_ms)
13039        {
13040            ClientDiagnosticsLease {
13041                state: if expires_at_ms <= captured_at_ms {
13042                    "expired".to_owned()
13043                } else {
13044                    "active".to_owned()
13045                },
13046                expires_at_ms: Some(expires_at_ms),
13047                error_code: None,
13048            }
13049        } else {
13050            ClientDiagnosticsLease {
13051                state: "none".to_owned(),
13052                expires_at_ms: None,
13053                error_code: None,
13054            }
13055        };
13056        let connectivity = match self.last_round.as_ref() {
13057            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
13058            Some(round) if round.status == "succeeded" => "online",
13059            Some(round)
13060                if round.status == "failed"
13061                    && round
13062                        .error_code
13063                        .as_deref()
13064                        .is_some_and(Self::retryable_transport_code) =>
13065            {
13066                "offline"
13067            }
13068            _ => "unknown",
13069        };
13070        Ok(ClientDiagnosticsSnapshot {
13071            version: CLIENT_DIAGNOSTICS_VERSION,
13072            captured_at_ms,
13073            host: ClientDiagnosticsHost {
13074                kind: "direct".to_owned(),
13075                role: "single".to_owned(),
13076                connectivity: connectivity.to_owned(),
13077                realtime: self.realtime_state,
13078                realtime_policy: self.realtime_policy,
13079                realtime_reason_code: self.realtime_reason_code.clone(),
13080                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
13081            },
13082            security_lifecycle: self.security_lifecycle().to_owned(),
13083            schema: ClientDiagnosticsSchema {
13084                current_version: self.schema.version,
13085                upgrading: self.upgrading,
13086                required_version: self
13087                    .schema_floor
13088                    .as_ref()
13089                    .and_then(|floor| floor.required_schema_version),
13090                latest_version: self
13091                    .schema_floor
13092                    .as_ref()
13093                    .and_then(|floor| floor.latest_schema_version),
13094            },
13095            replica: ClientDiagnosticsReplica {
13096                local_revision: self.local_revision().to_string(),
13097                sync_needed: self.sync_needed,
13098                pending_outbox: self.outbox.len(),
13099            },
13100            lease,
13101            subscriptions: ordered_subscriptions,
13102            subscriptions_truncated,
13103            last_round: self.last_round.clone(),
13104            last_change: self.last_change.clone(),
13105            storage: self.diagnostics_storage(),
13106            query_failures: self.query_failures.clone(),
13107        })
13108    }
13109
13110    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
13111        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
13112            let page_count: i64 = self
13113                .conn
13114                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
13115            let page_size: i64 = self
13116                .conn
13117                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
13118            // Reuse compiled aggregates; every invocation still reads current storage.
13119            let outbox_bytes: i64 = self
13120                .conn
13121                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
13122                .query_row([], |row| row.get(0))?;
13123            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
13124                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
13125            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
13126            let blob_bytes = if self.schema_has_blobs() {
13127                self.conn
13128                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
13129                    .query_row([], |row| row.get(0))?
13130            } else {
13131                0
13132            };
13133            let pressure = self
13134                .limits
13135                .blob_cache_max_bytes
13136                .is_some_and(|limit| blob_bytes > limit);
13137            Ok(ClientDiagnosticsStorage {
13138                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
13139                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
13140                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
13141                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
13142                retained_outcome_entries: Some(outcome_entries.max(0)),
13143                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
13144                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
13145            })
13146        };
13147        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
13148            status: "unreadable".to_owned(),
13149            database_bytes_approx: None,
13150            pending_outbox_bytes_approx: None,
13151            retained_outcome_bytes_approx: None,
13152            retained_outcome_entries: None,
13153            blob_cache_bytes_approx: None,
13154            pressure_reason_code: None,
13155        })
13156    }
13157
13158    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
13159        self.change_queue.drain(..).collect()
13160    }
13161
13162    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
13163        let intents = self.sync_intent_queue.drain(..).collect();
13164        if self.transport_enabled {
13165            intents
13166        } else {
13167            vec![SyncIntent::None]
13168        }
13169    }
13170
13171    fn schedule_background_retry(&mut self) -> u64 {
13172        let delay_ms = self.retry_delay_ms;
13173        self.sync_intent_queue
13174            .push_back(SyncIntent::Background { delay_ms });
13175        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
13176        self.round_retry_delay_ms = Some(delay_ms);
13177        delay_ms
13178    }
13179
13180    fn reset_background_retry(&mut self) {
13181        self.retry_delay_ms = 250;
13182    }
13183
13184    fn retryable_transport_code(code: &str) -> bool {
13185        code == "transport.failed"
13186            || code == "transport.timeout"
13187            || code == "transport.unavailable"
13188            || code == "sync.transport_failed"
13189    }
13190
13191    /// §1.8: the transport reported a refused or unreachable endpoint. The
13192    /// native transport says `connect` (DNS failure, connection refused,
13193    /// reset, or aborted); a browser fetch transport says `network`. Repeating
13194    /// the request on a backoff repairs neither, so no retry is scheduled.
13195    fn unreachable_transport(details: &Option<serde_json::Value>) -> bool {
13196        matches!(
13197            details
13198                .as_ref()
13199                .and_then(|details| details.get("causeKind"))
13200                .and_then(serde_json::Value::as_str),
13201            Some("connect" | "network")
13202        )
13203    }
13204
13205    /// §1.5: a segment fetch that fails inside an otherwise complete round is
13206    /// invisible to the server (the round response arrived whole), so its code
13207    /// rides the following requests like a socket-round failure. A code the
13208    /// frame grammar rejects is not reportable and the previous code stays.
13209    fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
13210        if ssp2::model::valid_round_failure_code(&error.code) {
13211            self.round_failure = Some(error.code.clone());
13212        }
13213        SectionError::Abort(error.code, error.message)
13214    }
13215
13216    /// A failed round worth a background retry: a transport failure, or a
13217    /// §10.2 code the catalog marks retryable (clients may hardcode the
13218    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
13219    /// on the server's `retryable` flag.
13220    fn retryable_failure_code(code: &str) -> bool {
13221        Self::retryable_transport_code(code)
13222            || matches!(
13223                code,
13224                "sync.auth_required"
13225                    | "sync.auth_lease_required"
13226                    | "sync.auth_lease_revoked"
13227                    | "sync.internal_error"
13228                    | "sync.idempotency_cache_miss"
13229                    | "sync.schema_not_ready"
13230                    | "sync.segment_expired"
13231                    | "sync.rate_limited"
13232                    | "sync.websocket_connection_limit"
13233            )
13234    }
13235
13236    fn diagnostic_code(code: &str) -> String {
13237        let valid = !code.is_empty()
13238            && code.len() <= 96
13239            && code.contains('.')
13240            && code
13241                .bytes()
13242                .next()
13243                .is_some_and(|byte| byte.is_ascii_lowercase())
13244            && code.bytes().all(|byte| {
13245                byte.is_ascii_lowercase()
13246                    || byte.is_ascii_digit()
13247                    || matches!(byte, b'.' | b'_' | b'-')
13248            });
13249        if valid {
13250            code.to_owned()
13251        } else {
13252            "client.unknown_failure".to_owned()
13253        }
13254    }
13255
13256    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
13257        if self.sync_needed != value {
13258            if self.begin_observation("syncular_status").is_ok() {
13259                self.sync_needed = value;
13260                let batch = ChangeAccumulator {
13261                    status: true,
13262                    ..ChangeAccumulator::default()
13263                };
13264                if self.finish_observation("syncular_status", batch).is_err() {
13265                    self.rollback_observation("syncular_status");
13266                }
13267            } else {
13268                self.sync_needed = value;
13269            }
13270        }
13271        if value && interactive {
13272            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13273        }
13274    }
13275
13276    fn sqlite_failure(
13277        storage_failure: &RefCell<Option<QueryReadFailure>>,
13278        error: rusqlite::Error,
13279    ) -> String {
13280        let message = error.to_string();
13281        let failure = QueryReadFailure::from(error);
13282        let code = failure.code;
13283        if code.is_some() && storage_failure.borrow().is_none() {
13284            *storage_failure.borrow_mut() = Some(failure);
13285        }
13286        match code {
13287            Some(code) => format!("{code}: {message}"),
13288            None => message,
13289        }
13290    }
13291
13292    fn begin_observation(&self, name: &str) -> Result<(), String> {
13293        if let Some(failure) = &self.coalesced_failure {
13294            return Err(failure.clone());
13295        }
13296        // SQLite rolls a whole transaction back on some I/O and capacity
13297        // errors; later frames must not commit on their own after that.
13298        if self.coalesced.is_some() && self.conn.is_autocommit() {
13299            return Err("the coalesced apply transaction was rolled back".to_owned());
13300        }
13301        self.conn
13302            .execute_batch(&format!("SAVEPOINT {name}"))
13303            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13304    }
13305
13306    /// SPEC §1.4 rule 4: open a coalesced apply transaction unless a caller
13307    /// already owns one.
13308    fn begin_coalesced_apply(&mut self) -> Result<(), String> {
13309        if self.coalesced.is_some() || !self.conn.is_autocommit() {
13310            return Ok(());
13311        }
13312        self.conn
13313            .execute_batch("BEGIN")
13314            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13315        self.coalesced = Some(CoalescedApply {
13316            rows: 0,
13317            subs: self.subs.clone(),
13318            outbox: self.outbox.clone(),
13319            failed_commits: self.failed_commits.clone(),
13320            conflicts: self.conflicts.clone(),
13321            rejections: self.rejections.clone(),
13322            schema_floor: self.schema_floor.clone(),
13323            lease_state: self.lease_state.clone(),
13324            stopped: self.stopped,
13325            upgrading: self.upgrading,
13326            sync_needed: self.sync_needed,
13327            overlay_dirty: self.overlay_dirty.snapshot(),
13328            change_queue: self.change_queue.len(),
13329            sync_intent_queue: self.sync_intent_queue.len(),
13330            last_change: self.last_change.clone(),
13331        });
13332        Ok(())
13333    }
13334
13335    /// Account the rows of the next frame or block. A unit that would take the
13336    /// coalesced transaction past the bound commits the earlier units first.
13337    fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
13338        let Some(open) = &self.coalesced else {
13339            return Ok(());
13340        };
13341        if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
13342            self.commit_coalesced_apply()?;
13343            self.begin_coalesced_apply()?;
13344        }
13345        if let Some(open) = &mut self.coalesced {
13346            open.rows += rows;
13347        }
13348        Ok(())
13349    }
13350
13351    /// COMMIT the coalesced transaction. A failed COMMIT rolls every coalesced
13352    /// frame back, restores the in-memory state of its `BEGIN`, drops the
13353    /// change batches and sync intents queued since, and fails the rest of the
13354    /// response.
13355    fn commit_coalesced_apply(&mut self) -> Result<(), String> {
13356        let Some(open) = self.coalesced.take() else {
13357            return Ok(());
13358        };
13359        let committed = if self.conn.is_autocommit() {
13360            Err("the coalesced apply transaction was rolled back".to_owned())
13361        } else {
13362            self.conn
13363                .execute_batch("COMMIT")
13364                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13365        };
13366        let Err(error) = committed else {
13367            return Ok(());
13368        };
13369        if !self.conn.is_autocommit() {
13370            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13371                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13372            }
13373        }
13374        self.subs = open.subs;
13375        self.outbox = open.outbox;
13376        self.failed_commits = open.failed_commits;
13377        self.conflicts = open.conflicts;
13378        self.rejections = open.rejections;
13379        self.schema_floor = open.schema_floor;
13380        self.lease_state = open.lease_state;
13381        self.stopped = open.stopped;
13382        self.upgrading = open.upgrading;
13383        self.sync_needed = open.sync_needed;
13384        self.overlay_dirty.restore(open.overlay_dirty);
13385        self.change_queue.truncate(open.change_queue);
13386        self.sync_intent_queue.truncate(open.sync_intent_queue);
13387        self.last_change = open.last_change;
13388        self.coalesced_failure = Some(error.clone());
13389        Err(error)
13390    }
13391
13392    /// End the response's coalescing: COMMIT what is open and clear a failure.
13393    fn finish_coalesced_apply(&mut self) -> Result<(), String> {
13394        let committed = self.commit_coalesced_apply();
13395        match self.coalesced_failure.take() {
13396            Some(failure) => Err(failure),
13397            None => committed,
13398        }
13399    }
13400
13401    fn rollback_observation(&self, name: &str) {
13402        if let Err(error) = self
13403            .conn
13404            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
13405        {
13406            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
13407                if failure.rollback_failure.is_none() {
13408                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
13409                }
13410            }
13411        }
13412    }
13413
13414    fn finish_observation(
13415        &mut self,
13416        name: &str,
13417        mut batch: ChangeAccumulator,
13418    ) -> Result<(), String> {
13419        #[cfg(feature = "bench-internals")]
13420        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
13421        if self.failed_commits.iter().any(|(commit, _)| {
13422            commit
13423                .ops
13424                .iter()
13425                .any(|operation| batch.tables.contains_key(&operation.table))
13426        }) {
13427            batch.outcomes = true;
13428        }
13429        if !batch.touched() {
13430            self.conn
13431                .execute_batch(&format!("RELEASE {name}"))
13432                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13433            return Ok(());
13434        }
13435        let revision = self
13436            .local_revision()
13437            .checked_add(1)
13438            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
13439        self.conn
13440            .execute(
13441                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13442                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
13443            )
13444            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13445        let status = batch.status.then(|| self.status_snapshot());
13446        let event = ClientChangeBatch {
13447            revision: revision.to_string(),
13448            tables: batch
13449                .tables
13450                .into_iter()
13451                .map(|(table, scope_keys)| TableChange {
13452                    table,
13453                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
13454                })
13455                .collect(),
13456            windows: batch
13457                .windows
13458                .into_iter()
13459                .map(|((base_key, table), units)| WindowChange {
13460                    base_key,
13461                    table,
13462                    units: units.into_iter().collect(),
13463                })
13464                .collect(),
13465            status,
13466            conflicts_changed: batch.conflicts,
13467            rejections_changed: batch.rejections,
13468            outcomes_changed: batch.outcomes,
13469        };
13470        self.conn
13471            .execute_batch(&format!("RELEASE {name}"))
13472            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13473        let mut diagnostic_tables = event
13474            .tables
13475            .iter()
13476            .map(|entry| entry.table.clone())
13477            .collect::<BTreeSet<_>>()
13478            .into_iter()
13479            .collect::<Vec<_>>();
13480        let mut diagnostic_windows = event
13481            .windows
13482            .iter()
13483            .map(|entry| entry.table.clone())
13484            .collect::<BTreeSet<_>>()
13485            .into_iter()
13486            .collect::<Vec<_>>();
13487        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
13488            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
13489        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
13490        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
13491        self.last_change = Some(DiagnosticLastChange {
13492            revision: event.revision.clone(),
13493            recorded_at_ms: self.clock_now_ms(),
13494            tables: diagnostic_tables,
13495            windows: diagnostic_windows,
13496            domains_truncated,
13497            status_changed: event.status.is_some(),
13498            conflicts_changed: event.conflicts_changed,
13499            rejections_changed: event.rejections_changed,
13500            outcomes_changed: event.outcomes_changed,
13501        });
13502        self.change_queue.push_back(event);
13503        Ok(())
13504    }
13505
13506    fn record_scope_map(
13507        &self,
13508        batch: &mut ChangeAccumulator,
13509        table_name: &str,
13510        scopes: &[(String, Vec<String>)],
13511    ) {
13512        let Some(table) = self.schema.table(table_name) else {
13513            return;
13514        };
13515        for (variable, values) in scopes {
13516            let Some(scope) = table
13517                .scope_variables
13518                .iter()
13519                .find(|scope| &scope.variable == variable)
13520            else {
13521                continue;
13522            };
13523            for value in values {
13524                batch.scope(table_name, format!("{}:{value}", scope.prefix));
13525            }
13526        }
13527    }
13528
13529    /// Record a row's current scope keys from the base or visible table.
13530    fn record_row_scopes(
13531        &self,
13532        batch: &mut ChangeAccumulator,
13533        table_name: &str,
13534        row_id: &str,
13535        base: bool,
13536    ) -> bool {
13537        let Some(table) = self.schema.table(table_name) else {
13538            return false;
13539        };
13540        if table.scope_variables.is_empty() {
13541            return false;
13542        }
13543        let columns = table
13544            .scope_variables
13545            .iter()
13546            .map(|scope| quote_ident(&scope.column))
13547            .collect::<Vec<_>>()
13548            .join(", ");
13549        let full_table = if base {
13550            base_table(table_name)
13551        } else {
13552            visible_table(table_name)
13553        };
13554        let sql = format!(
13555            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
13556            row_id_predicate(table)
13557        );
13558        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
13559            return false;
13560        };
13561        let values = stmt.query_row(rusqlite::params![row_id], |row| {
13562            let mut values = Vec::with_capacity(table.scope_variables.len());
13563            for index in 0..table.scope_variables.len() {
13564                values.push(row.get::<_, Option<String>>(index)?);
13565            }
13566            Ok(values)
13567        });
13568        let Ok(values) = values else {
13569            return false;
13570        };
13571        let mut recorded = false;
13572        for (scope, value) in table.scope_variables.iter().zip(values) {
13573            if let Some(value) = value {
13574                batch.scope(table_name, format!("{}:{value}", scope.prefix));
13575                recorded = true;
13576            }
13577        }
13578        recorded
13579    }
13580
13581    fn record_commit_changes(
13582        &self,
13583        batch: &mut ChangeAccumulator,
13584        tables: &[String],
13585        changes: &[ssp2::model::Change],
13586    ) {
13587        #[cfg(feature = "bench-internals")]
13588        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
13589        for change in changes {
13590            let Some(table_name) = tables.get(change.table_index as usize) else {
13591                continue;
13592            };
13593            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
13594            if let Some(table) = self.schema.table(table_name) {
13595                for (variable, value) in &change.scopes {
13596                    if let Some(scope) = table
13597                        .scope_variables
13598                        .iter()
13599                        .find(|scope| &scope.variable == variable)
13600                    {
13601                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
13602                        precise = true;
13603                    }
13604                }
13605            }
13606            if !precise {
13607                batch.table(table_name);
13608            }
13609        }
13610    }
13611
13612    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
13613        if effective.is_empty() {
13614            return false;
13615        }
13616        let Some(table) = self.schema.table(table_name) else {
13617            return false;
13618        };
13619        let mut clauses = Vec::new();
13620        let mut params = Vec::new();
13621        for (variable, values) in effective {
13622            let Some(column) = table.scope_column(variable) else {
13623                return false;
13624            };
13625            if values.is_empty() {
13626                return false;
13627            }
13628            let placeholders = values
13629                .iter()
13630                .map(|value| {
13631                    params.push(SqlValue::Text(value.clone()));
13632                    "?"
13633                })
13634                .collect::<Vec<_>>()
13635                .join(", ");
13636            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
13637        }
13638        let sql = format!(
13639            "SELECT 1 FROM {} WHERE {} LIMIT 1",
13640            base_table(table_name),
13641            clauses.join(" AND ")
13642        );
13643        self.conn
13644            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
13645            .is_ok()
13646    }
13647
13648    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
13649    pub fn upgrading(&self) -> bool {
13650        self.upgrading
13651    }
13652
13653    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
13654    /// keeping this client's local database (identity, outbox, tables). The
13655    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
13656    /// version increases. Recreation runs the same marker guard as opening
13657    /// a durable replica, holds the guard and every write it authorizes under
13658    /// one write transaction, and preserves the current client (compiled
13659    /// schema, outbox, tables) on refusal.
13660    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
13661        let new_schema = parse_schema_json(schema_json)?;
13662        validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
13663        self.conn
13664            .execute_batch("BEGIN IMMEDIATE")
13665            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13666        let prior_schema = std::mem::replace(&mut self.schema, new_schema);
13667        // §7.4.3/§7.4.4: a reset inside this transaction mutates in-memory
13668        // state (dropped outbox commits, rejections, upgrade and floor flags,
13669        // overlay dirtiness) and appends change/intent batches. A COMMIT that
13670        // fails after the reset succeeded rolls the database back, so every
13671        // one of those fields is captured here and restored with it.
13672        let prior_subs = self.subs.clone();
13673        let prior_outbox = self.outbox.clone();
13674        let prior_rejections = self.rejections.clone();
13675        let prior_upgrading = self.upgrading;
13676        let prior_stopped = self.stopped;
13677        let prior_schema_floor = self.schema_floor.clone();
13678        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13679        let prior_sync_needed = self.sync_needed;
13680        let prior_active_round = self.active_round;
13681        let prior_changes = self.change_queue.len();
13682        let prior_intents = self.sync_intent_queue.len();
13683        let prior_last_change = self.last_change.clone();
13684        let guarded = (|| -> Result<(), String> {
13685            // §7.4.2: the marker is re-read under the write lock the caller
13686            // holds, so a replica another process upgraded meanwhile refuses
13687            // this recreation instead of having its marker and tables
13688            // downgraded.
13689            let marker = read_local_schema_version(&self.conn, self.schema.version)?;
13690            self.create_bookkeeping_tables()?;
13691            match marker {
13692                Some(version) if version < self.schema.version => self.run_schema_reset()?,
13693                None => {
13694                    self.create_synced_tables()?;
13695                    self.persist_schema_version()?;
13696                }
13697                _ => {}
13698            }
13699            // §7.4.2: recreation IS the boot for the in-memory core, so reload the
13700            // durable outbox the same-version path is about to replay. Only the
13701            // outbox is reloaded: the recovery below reads it, and the caller's
13702            // rollback snapshot already covers this field.
13703            self.outbox = self.load_outbox()?;
13704            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
13705            // this guarded boundary once the schema and marker agree. A bump went
13706            // through `run_schema_reset`, which performs the same recovery after
13707            // it creates the new physical tables and before its final replay.
13708            self.drop_unencodable_outbox(false, None)?;
13709            if marker == Some(self.schema.version)
13710                && (!self.outbox.is_empty()
13711                    || !self.failed_commits.is_empty()
13712                    || self.has_acknowledged_rows())
13713            {
13714                self.overlay_dirty.set(true);
13715                self.rebuild_overlay()?;
13716            }
13717            self.prune_unknown_subscriptions(false)?;
13718            self.save_subscription_scope_schema();
13719            // The conformance recreate is the in-memory equivalent of reopening
13720            // a durable client. Apply the same startup catch-up contract even
13721            // when the schema itself did not change.
13722            self.enqueue_startup_sync_if_needed()?;
13723            Ok(())
13724        })();
13725        let outcome = match guarded {
13726            Ok(()) => self
13727                .conn
13728                .execute_batch("COMMIT")
13729                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
13730            Err(error) => Err(error),
13731        };
13732        if let Err(error) = outcome {
13733            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13734                // Keep a failed rollback's typed storage classification.
13735                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13736            }
13737            self.schema = prior_schema;
13738            self.subs = prior_subs;
13739            self.outbox = prior_outbox;
13740            self.rejections = prior_rejections;
13741            self.upgrading = prior_upgrading;
13742            self.stopped = prior_stopped;
13743            self.schema_floor = prior_schema_floor;
13744            self.overlay_dirty.restore(prior_overlay_dirty);
13745            self.sync_needed = prior_sync_needed;
13746            self.active_round = prior_active_round;
13747            self.change_queue.truncate(prior_changes);
13748            self.sync_intent_queue.truncate(prior_intents);
13749            self.last_change = prior_last_change;
13750            // Prepared insert statements were derived from the reverted schema.
13751            self.insert_sql.borrow_mut().clear();
13752            return Err(error);
13753        }
13754        Ok(())
13755    }
13756
13757    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
13758        let startup_work = !self.stopped
13759            && (!self.outbox.is_empty()
13760                || self.has_acknowledged_rows()
13761                || !self.load_pending_evictions()?.is_empty()
13762                || self.subs.iter().any(|sub| sub.state == SubState::Active));
13763        if startup_work {
13764            self.sync_needed = true;
13765            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13766        }
13767        Ok(())
13768    }
13769
13770    /// A native client persists schema-floor stops across process restarts.
13771    /// Once the running app already satisfies that floor, the persisted stop
13772    /// is only stale evidence from an older server round (for example, the
13773    /// server was restarted after catching up to an app that was ahead).
13774    /// Clear it and let the normal startup pull re-negotiate. If the server is
13775    /// still incompatible it will return the floor again in that first round.
13776    fn clear_satisfied_persisted_schema_floor(&mut self) {
13777        let satisfied = self
13778            .schema_floor
13779            .as_ref()
13780            .and_then(|floor| floor.required_schema_version)
13781            .is_some_and(|required| self.schema.version >= required);
13782        if !satisfied {
13783            return;
13784        }
13785        self.schema_floor = None;
13786        self.stopped = false;
13787        self.delete_meta(SCHEMA_FLOOR_KEY);
13788    }
13789
13790    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
13791    /// path already swept it; this covers a same-version or fresh-install open,
13792    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
13793    /// an aware binary can apply.
13794    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
13795        let Some(replica_path) = self.previous_version_replica_path() else {
13796            return Ok(());
13797        };
13798        let max_age_ms = self
13799            .previous_version
13800            .filter(|config| config.enabled)
13801            .map(|config| config.max_age_ms);
13802        reconcile_previous_version_at_boot(
13803            &self.conn,
13804            &replica_path,
13805            self.schema.version,
13806            max_age_ms,
13807            self.clock_now_ms(),
13808        )
13809    }
13810
13811    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
13812    /// no file — an in-memory replica returns an empty path, and a `file:` URI
13813    /// has no plain sibling path — so the feature resolves as off there.
13814    fn previous_version_replica_path(&self) -> Option<String> {
13815        let path = self.conn.path()?;
13816        if path.is_empty() || path.starts_with("file:") {
13817            return None;
13818        }
13819        Some(path.to_owned())
13820    }
13821
13822    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
13823    /// and leaseState. Drops/recreates every synced table from the new
13824    /// schema, resets subscription sync-state (keeping registrations), clears
13825    /// the schema-floor stop state, rewrites the marker, drops outbox commits
13826    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
13827    fn run_schema_reset(&mut self) -> Result<(), String> {
13828        self.begin_observation("syncular_schema_reset")?;
13829        let prior_subs = self.subs.clone();
13830        let prior_outbox = self.outbox.clone();
13831        let prior_rejections = self.rejections.clone();
13832        let prior_upgrading = self.upgrading;
13833        let prior_stopped = self.stopped;
13834        let prior_schema_floor = self.schema_floor.clone();
13835        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13836        let prior_sync_needed = self.sync_needed;
13837        let prior_active_round = self.active_round;
13838        let mut batch = ChangeAccumulator::default();
13839        let result = self
13840            .run_schema_reset_observed(&mut batch, true)
13841            .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
13842        if let Err(error) = result {
13843            self.rollback_observation("syncular_schema_reset");
13844            self.subs = prior_subs;
13845            self.outbox = prior_outbox;
13846            self.rejections = prior_rejections;
13847            self.upgrading = prior_upgrading;
13848            self.stopped = prior_stopped;
13849            self.schema_floor = prior_schema_floor;
13850            self.overlay_dirty.restore(prior_overlay_dirty);
13851            self.sync_needed = prior_sync_needed;
13852            self.active_round = prior_active_round;
13853            return Err(error);
13854        }
13855        Ok(())
13856    }
13857
13858    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
13859        if self.get_meta(LOG_EPOCH_KEY).is_none() {
13860            self.begin_observation("syncular_epoch_acquisition")?;
13861            let previous_sync_needed = self.sync_needed;
13862            if let Err(error) = self.conn.execute(
13863                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13864                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13865            ) {
13866                self.rollback_observation("syncular_epoch_acquisition");
13867                return Err(Self::sqlite_failure(&self.storage_failure, error));
13868            }
13869            self.sync_needed = true;
13870            let batch = ChangeAccumulator {
13871                status: true,
13872                ..ChangeAccumulator::default()
13873            };
13874            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
13875                self.rollback_observation("syncular_epoch_acquisition");
13876                self.sync_needed = previous_sync_needed;
13877                return Err(error);
13878            }
13879            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13880            return Ok(Vec::new());
13881        }
13882        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
13883        self.begin_observation("syncular_log_epoch_reset")?;
13884        let prior_subs = self.subs.clone();
13885        let prior_outbox = self.outbox.clone();
13886        let prior_rejections = self.rejections.clone();
13887        let prior_upgrading = self.upgrading;
13888        let prior_stopped = self.stopped;
13889        let prior_schema_floor = self.schema_floor.clone();
13890        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13891        let prior_sync_needed = self.sync_needed;
13892        let prior_active_round = self.active_round;
13893        let mut batch = ChangeAccumulator::default();
13894        let result = self
13895            .run_schema_reset_observed(&mut batch, false)
13896            .and_then(|()| {
13897                self.conn
13898                    .execute(
13899                        "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13900                        rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13901                    )
13902                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13903                self.sync_needed = true;
13904                batch.status = true;
13905                self.finish_observation("syncular_log_epoch_reset", batch)
13906            });
13907        if let Err(error) = result {
13908            self.rollback_observation("syncular_log_epoch_reset");
13909            self.subs = prior_subs;
13910            self.outbox = prior_outbox;
13911            self.rejections = prior_rejections;
13912            self.upgrading = prior_upgrading;
13913            self.stopped = prior_stopped;
13914            self.schema_floor = prior_schema_floor;
13915            self.overlay_dirty.restore(prior_overlay_dirty);
13916            self.sync_needed = prior_sync_needed;
13917            self.active_round = prior_active_round;
13918            return Err(error);
13919        }
13920        self.sync_intent_queue.push_back(SyncIntent::Interactive);
13921        Ok(resets)
13922    }
13923
13924    fn run_schema_reset_observed(
13925        &mut self,
13926        batch: &mut ChangeAccumulator,
13927        capture: bool,
13928    ) -> Result<(), String> {
13929        // §7.4.2: the reset is the only path that wipes local tables for a
13930        // version bump, and it rewrites the marker last. Re-read the marker
13931        // under this reset's transaction: a replica another process upgraded
13932        // meanwhile refuses here instead of being reset to this client's
13933        // older schema.
13934        read_local_schema_version(&self.conn, self.schema.version)?;
13935        self.conn
13936            .execute_batch(
13937                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
13938            )
13939            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13940        self.cancel_sync_round();
13941        self.upgrading = true;
13942        batch.status = true;
13943        // RFC 0005 D6: classify the pending outbox against the NEW compiled
13944        // schema BEFORE the wipe, and record the advisory audit. This pass
13945        // drops nothing, so the audit sees the original pending intent; the
13946        // §7.4.4 send-time drop and the intrinsic value recovery later in this
13947        // reset are the paths that remove a commit. The classification is
13948        // shared with the send-time drop so the two cannot disagree about what
13949        // is incompatible.
13950        if self.previous_version.is_some_and(|config| config.enabled) {
13951            let previous_version = self
13952                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13953                .and_then(|value| value.parse::<i32>().ok())
13954                .unwrap_or(self.schema.version);
13955            let pending = self
13956                .outbox
13957                .iter()
13958                .map(|commit| PendingCommitAudit {
13959                    commit_id: &commit.client_commit_id,
13960                    operations: commit_audit_operations(commit),
13961                })
13962                .collect::<Vec<_>>();
13963            crate::previous_version::write_previous_version_audit(
13964                &self.conn,
13965                &crate::previous_version::build_previous_version_audit(
13966                    &self.schema,
13967                    &pending,
13968                    previous_version,
13969                    self.schema.version,
13970                    self.clock_now_ms(),
13971                ),
13972            );
13973        }
13974        // RFC 0005 D5: the container lives in its own FILE, so the reset can
13975        // never reach it. Sweep first (unconditional — also on the log-epoch
13976        // reset, which captures nothing), then capture BEFORE the wipe drops
13977        // any row. A refused capture records its reason and leaves no file.
13978        if let Some(replica_path) = self.previous_version_replica_path() {
13979            sweep_previous_version_container(&self.conn, &replica_path)?;
13980            if capture {
13981                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
13982                    let previous_version = self
13983                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13984                        .and_then(|value| value.parse::<i32>().ok())
13985                        .unwrap_or(self.schema.version);
13986                    capture_previous_version_from_replica(
13987                        &self.conn,
13988                        &replica_path,
13989                        previous_version,
13990                        self.schema.version,
13991                        &config,
13992                        self.clock_now_ms(),
13993                    )?;
13994                }
13995            }
13996        }
13997        for table in &self.schema.tables {
13998            batch.table(&table.name);
13999        }
14000        for (base_key, unit, table) in self.load_registered_window_units() {
14001            batch.window(&base_key, &table, &unit);
14002        }
14003        if capture {
14004            self.prune_unknown_subscriptions(true)?;
14005        }
14006        // The per-table insert SQL is derived from the OLD column lists.
14007        self.insert_sql.borrow_mut().clear();
14008        self.overlay_dirty.set(true);
14009        // Drop every synced table (base + visible) that currently exists —
14010        // discovered from sqlite_master so a bump that adds/removes tables is
14011        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
14012        // and the blob cache) are preserved; base tables are `_syncular_base_*`
14013        // so they are matched explicitly, not by the bookkeeping filter.
14014        // Drop virtual tables first so SQLite removes every FTS shadow table
14015        // atomically instead of the generic discovery tearing it apart.
14016        let virtual_tables: Vec<String> = {
14017            let mut stmt = self
14018                .conn
14019                .prepare(
14020                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
14021                )
14022                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14023            let rows = stmt
14024                .query_map([], |row| row.get::<_, String>(0))
14025                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14026            rows.filter_map(Result::ok)
14027                .filter(|name| is_synced_table_name(name))
14028                .collect()
14029        };
14030        for name in virtual_tables {
14031            self.conn
14032                .execute_batch(&format!(
14033                    "DROP TABLE IF EXISTS {}",
14034                    quote_ident(&format!("_syncular_fts_{name}"))
14035                ))
14036                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14037            self.conn
14038                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
14039                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14040        }
14041        let existing: Vec<String> = {
14042            let mut stmt = self
14043                .conn
14044                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
14045                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14046            let rows = stmt
14047                .query_map([], |row| row.get::<_, String>(0))
14048                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14049            rows.filter_map(Result::ok)
14050                .filter(|name| is_synced_table_name(name))
14051                .collect()
14052        };
14053        for name in &existing {
14054            if let Some(table) = name.strip_prefix("_syncular_base_") {
14055                batch.table(table);
14056            } else if !name.starts_with("_syncular_") {
14057                batch.table(name);
14058            }
14059        }
14060        for name in existing {
14061            self.conn
14062                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
14063                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14064        }
14065        // Recreate the synced tables from the NEW schema.
14066        self.create_synced_tables()?;
14067        // Reset every subscription's sync-state, keeping the registration.
14068        for sub in &mut self.subs {
14069            sub.cursor = -1;
14070            sub.bootstrap_state = None;
14071            sub.effective = None;
14072            sub.state = SubState::Active;
14073            sub.reason_code = None;
14074            sub.synced_once = false;
14075        }
14076        let subs = self.subs.clone();
14077        for sub in &subs {
14078            self.persist_sub(sub)?;
14079        }
14080        // The stop state is over: this client now ships a servable schema.
14081        self.stopped = false;
14082        self.schema_floor = None;
14083        self.delete_meta(SCHEMA_FLOOR_KEY);
14084        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
14085        self.persist_schema_version()?;
14086        self.save_subscription_scope_schema();
14087        // §7.1/§7.4.4: the previous-version capture and audit above saw the
14088        // original pending intent; the synced tables now carry the new schema, so
14089        // recover intrinsically codec-invalid persisted values here, before the
14090        // final replay. A pending upsert that names a column the new schema
14091        // removed stays pending for the send-time `drop_incompatible_outbox`
14092        // prepass. The reset wrapper snapshots and restores the outbox,
14093        // rejections, and change queues, so a failed commit rolls this recovery
14094        // back with the rest of the reset.
14095        // §7.4.4: the enclosing reset observation owns the publication, so the
14096        // recovery contributes its rejection, outcome, and table domains to it.
14097        self.drop_unencodable_outbox(false, Some(batch))?;
14098        // Re-apply the surviving outbox optimistically over the empty tables.
14099        self.rebuild_overlay()?;
14100        Ok(())
14101    }
14102
14103    /// §7.4.4 send-time prepass: a persisted upsert whose values reference a
14104    /// column the current schema lacks (or a removed table) cannot be encoded
14105    /// (`commit_audit_operations` inspects upserts, so a value-free delete stays
14106    /// encodable and is validated by the server). Drop the commit, undo its
14107    /// optimistic projection, and raise a client-local
14108    /// `sync.outbox_incompatible` rejection.
14109    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
14110        let incompatible = self
14111            .outbox
14112            .iter()
14113            .filter(|commit| {
14114                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
14115            })
14116            .cloned()
14117            .collect::<Vec<_>>();
14118        if incompatible.is_empty() {
14119            return Ok(false);
14120        }
14121        // §7.4.4: one observation covers the durable outcome writes, the outbox
14122        // removal, the in-memory drop, and the overlay rebuild, so a storage
14123        // fault leaves the replica and the client unchanged and publishes no
14124        // change; a successful drop publishes exactly one batch carrying the
14125        // rejections, outcomes, status, and the tables whose projection changed.
14126        let prior_outbox = self.outbox.clone();
14127        let prior_rejections = self.rejections.clone();
14128        let prior_overlay_dirty = self.overlay_dirty.snapshot();
14129        let owns_transaction = self.conn.is_autocommit();
14130        self.begin_observation("syncular_outbox_drop")?;
14131        let mut batch = ChangeAccumulator::default();
14132        let dropped = (|| -> Result<(), String> {
14133            let mut rejections = Vec::new();
14134            for commit in &incompatible {
14135                let results = commit
14136                    .ops
14137                    .iter()
14138                    .enumerate()
14139                    .map(|(op_index, operation)| {
14140                        let rejection = RejectionRecord {
14141                            client_commit_id: commit.client_commit_id.clone(),
14142                            op_index: op_index as i32,
14143                            code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
14144                            message: "the persisted commit cannot encode under the current schema"
14145                                .to_owned(),
14146                            retryable: false,
14147                            details: None,
14148                            operation: Some(CommitOperation::from(operation)),
14149                        };
14150                        rejections.push(rejection.clone());
14151                        CommitOperationOutcome::Error { rejection }
14152                    })
14153                    .collect::<Vec<_>>();
14154                self.persist_commit_outcome(
14155                    &commit.client_commit_id,
14156                    CommitOutcomeStatus::Rejected,
14157                    &results,
14158                    Some(&commit.ops),
14159                )?;
14160                self.delete_outbox_persisted(&commit.client_commit_id)?;
14161                // A removed table has no local projection to invalidate; every
14162                // surviving table the dropped commit touched does.
14163                for operation in &commit.ops {
14164                    if self.schema.table(&operation.table).is_some() {
14165                        batch.table(&operation.table);
14166                    }
14167                }
14168            }
14169            self.prune_commit_outcomes()?;
14170            let incompatible_ids = incompatible
14171                .iter()
14172                .map(|commit| commit.client_commit_id.as_str())
14173                .collect::<BTreeSet<_>>();
14174            self.outbox
14175                .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
14176            self.rejections.extend(rejections);
14177            // The dropped commits' purely-optimistic rows are undone: the
14178            // visible projection is re-derived from the surviving base + outbox.
14179            self.overlay_dirty.set(true);
14180            self.rebuild_overlay()?;
14181            batch.status = true;
14182            batch.rejections = true;
14183            batch.outcomes = true;
14184            Ok(())
14185        })();
14186        let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
14187        if let Err(error) = result {
14188            // An outer RELEASE is the COMMIT. Roll back that transaction
14189            // directly: retrying RELEASE after ROLLBACK TO still needs the
14190            // reader's lock. A nested observation only rolls back its savepoint.
14191            if owns_transaction {
14192                if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14193                    let mut failure = self.storage_failure.borrow_mut();
14194                    let failure =
14195                        failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14196                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14197                }
14198            } else {
14199                self.rollback_observation("syncular_outbox_drop");
14200            }
14201            self.outbox = prior_outbox;
14202            self.rejections = prior_rejections;
14203            self.overlay_dirty.restore(prior_overlay_dirty);
14204            return Err(error);
14205        }
14206        Ok(true)
14207    }
14208
14209    /// §7.4.3: (re)create the base + visible table pair for every synced
14210    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
14211    fn create_synced_tables(&self) -> Result<(), String> {
14212        for table in &self.schema.tables {
14213            // The base half + the visible half form the synced-table pair. An
14214            // index name is global in SQLite, so the base half's indexes are
14215            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
14216            for (full, index_prefix) in [
14217                (base_table(&table.name), "_syncular_base_"),
14218                (visible_table(&table.name), ""),
14219            ] {
14220                let mut cols: Vec<String> =
14221                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
14222                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
14223                let sql = format!(
14224                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
14225                    cols.join(", "),
14226                    quote_ident(&table.primary_key)
14227                );
14228                self.conn
14229                    .execute(&sql, [])
14230                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14231                // Local secondary indexes (CREATE INDEX subset). Created on
14232                // both halves so mirror reads hit an index on either. Runs on
14233                // both the initial create and the §7.4.3 reset recreate path.
14234                for index in &table.indexes {
14235                    let unique = if index.unique { "UNIQUE " } else { "" };
14236                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
14237                    let cols_sql = index
14238                        .columns
14239                        .iter()
14240                        .map(|c| quote_ident(c))
14241                        .collect::<Vec<_>>()
14242                        .join(", ");
14243                    let index_sql = format!(
14244                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
14245                    );
14246                    self.conn
14247                        .execute(&index_sql, [])
14248                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14249                }
14250            }
14251        }
14252        // FTS exists only on the visible half. It is a local search
14253        // projection, never a synced/base table or a wire-schema member.
14254        for table in &self.schema.tables {
14255            for index in &table.fts_indexes {
14256                self.create_fts_projection(table, index)?;
14257            }
14258        }
14259        Ok(())
14260    }
14261
14262    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
14263        let count: i64 = self
14264            .conn
14265            .query_row(
14266                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
14267                rusqlite::params![index.name],
14268                |row| row.get(0),
14269            )
14270            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14271        Ok(count > 0)
14272    }
14273
14274    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
14275        for suffix in ["ai", "ad", "au"] {
14276            self.conn
14277                .execute(
14278                    &format!(
14279                        "DROP TRIGGER IF EXISTS {}",
14280                        quote_ident(&format!("{}_{suffix}", index.name))
14281                    ),
14282                    [],
14283                )
14284                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14285        }
14286        Ok(())
14287    }
14288
14289    fn create_fts_triggers(
14290        &self,
14291        table: &TableSchema,
14292        index: &FtsIndexSchema,
14293    ) -> Result<(), String> {
14294        let fts = quote_ident(&index.name);
14295        let source = visible_table(&table.name);
14296        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14297        let pk = quote_ident(&table.primary_key);
14298        let projection_columns = std::iter::once(source_id.clone())
14299            .chain(index.columns.iter().map(|column| quote_ident(column)))
14300            .collect::<Vec<_>>()
14301            .join(", ");
14302        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
14303            .chain(
14304                index
14305                    .columns
14306                    .iter()
14307                    .map(|column| format!("new.{}", quote_ident(column))),
14308            )
14309            .collect::<Vec<_>>()
14310            .join(", ");
14311        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14312        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)");
14313        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)");
14314        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})");
14315        let bi = quote_ident(&format!("{}_bi", index.name));
14316        let ai = quote_ident(&format!("{}_ai", index.name));
14317        let ad = quote_ident(&format!("{}_ad", index.name));
14318        let au = quote_ident(&format!("{}_au", index.name));
14319        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
14320        let sql = format!(
14321            "DROP TRIGGER IF EXISTS {bi};
14322             DROP TRIGGER IF EXISTS {ai};
14323             DROP TRIGGER IF EXISTS {ad};
14324             DROP TRIGGER IF EXISTS {au};
14325             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
14326             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
14327             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
14328             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
14329        );
14330        self.conn
14331            .execute_batch(&sql)
14332            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14333    }
14334
14335    fn rebuild_fts_projection(
14336        &self,
14337        table: &TableSchema,
14338        index: &FtsIndexSchema,
14339    ) -> Result<(), String> {
14340        let fts = quote_ident(&index.name);
14341        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14342        let pk = quote_ident(&table.primary_key);
14343        let indexed_columns = index
14344            .columns
14345            .iter()
14346            .map(|column| quote_ident(column))
14347            .collect::<Vec<_>>();
14348        let projection_columns = std::iter::once(source_id.clone())
14349            .chain(indexed_columns.iter().cloned())
14350            .collect::<Vec<_>>()
14351            .join(", ");
14352        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14353        let sql = format!(
14354            "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};",
14355            columns = indexed_columns.join(", "),
14356            source = visible_table(&table.name),
14357        );
14358        self.conn
14359            .execute_batch(&sql)
14360            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14361    }
14362
14363    fn create_fts_projection(
14364        &self,
14365        table: &TableSchema,
14366        index: &FtsIndexSchema,
14367    ) -> Result<(), String> {
14368        self.begin_observation("syncular_fts_schema")?;
14369        let result = (|| {
14370            let existed = self.fts_projection_exists(index)?;
14371            let tokenizer = index.tokenize.replace('\'', "''");
14372            let columns = index
14373                .columns
14374                .iter()
14375                .map(|column| quote_ident(column))
14376                .collect::<Vec<_>>()
14377                .join(", ");
14378            let sql = format!(
14379            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
14380            fts = quote_ident(&index.name),
14381            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
14382        );
14383            self.conn.execute(&sql, []).map_err(|error| {
14384                format!(
14385                    "cannot create local FTS5 projection {:?}: {error}",
14386                    index.name
14387                )
14388            })?;
14389            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14390            let mapped: bool = self
14391                .conn
14392                .query_row(
14393                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
14394                    [format!("_syncular_fts_{}", index.name)],
14395                    |row| row.get(0),
14396                )
14397                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14398            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))?;
14399            if !mapped && existed {
14400                self.conn
14401                    .execute_batch(&format!(
14402                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
14403                        quote_ident(FTS_SOURCE_ID_COLUMN),
14404                        quote_ident(&index.name)
14405                    ))
14406                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14407            }
14408            self.create_fts_triggers(table, index)?;
14409            if !existed {
14410                self.rebuild_fts_projection(table, index)?;
14411            }
14412            Ok(())
14413        })();
14414        if let Err(error) = result {
14415            self.rollback_observation("syncular_fts_schema");
14416            return Err(error);
14417        }
14418        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
14419            self.rollback_observation("syncular_fts_schema");
14420            return Err(error.to_string());
14421        }
14422        Ok(())
14423    }
14424
14425    // -- persistence write-through --------------------------------------------
14426
14427    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
14428        let state = serde_json::json!({
14429            "requested": scope_map_to_json(&sub.requested),
14430            "params": sub.params,
14431            "cursor": sub.cursor,
14432            "bootstrapState": sub.bootstrap_state,
14433            "status": sub.state.name(),
14434            "reasonCode": sub.reason_code,
14435            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14436            "syncedOnce": sub.synced_once,
14437        });
14438        self.conn.execute(
14439            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
14440            rusqlite::params![sub.id, sub.table, state.to_string()],
14441        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14442    }
14443
14444    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
14445        let ops: Vec<Value> = commit
14446            .ops
14447            .iter()
14448            .map(|op| {
14449                serde_json::json!({
14450                    "op": if op.upsert { "upsert" } else { "delete" },
14451                    "table": op.table,
14452                    "rowId": op.row_id,
14453                    "baseVersion": op.base_version,
14454                    "values": op.values.clone().map(Value::Object),
14455                })
14456            })
14457            .collect();
14458        self.conn
14459            .execute(
14460                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
14461                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
14462            )
14463            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14464        for operation in &commit.ops {
14465            let Some(values) = &operation.values else {
14466                continue;
14467            };
14468            let Some(table) = self.schema.table(&operation.table) else {
14469                continue;
14470            };
14471            for column in table
14472                .columns
14473                .iter()
14474                .filter(|column| column.ty == ColumnType::BlobRef)
14475            {
14476                let Some(Value::String(raw)) = values.get(&column.name) else {
14477                    continue;
14478                };
14479                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
14480                    continue;
14481                };
14482                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
14483                    continue;
14484                };
14485                self.conn
14486                    .execute(
14487                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
14488                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
14489                        rusqlite::params![commit.client_commit_id, blob_id],
14490                    )
14491                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14492            }
14493        }
14494        Ok(())
14495    }
14496
14497    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
14498        if let Some(commit) = self
14499            .outbox
14500            .iter()
14501            .find(|commit| commit.client_commit_id == client_commit_id)
14502        {
14503            for operation in &commit.ops {
14504                self.overlay_dirty.table(&operation.table);
14505            }
14506        }
14507
14508        self.conn
14509            .execute(
14510                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
14511                rusqlite::params![client_commit_id],
14512            )
14513            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14514        self.conn
14515            .execute(
14516                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
14517                rusqlite::params![client_commit_id],
14518            )
14519            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14520        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)", [])
14521            .map(|_| ())
14522            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14523    }
14524
14525    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
14526        match status {
14527            CommitOutcomeStatus::Applied => "applied",
14528            CommitOutcomeStatus::Cached => "cached",
14529            CommitOutcomeStatus::Conflict => "conflict",
14530            CommitOutcomeStatus::Rejected => "rejected",
14531        }
14532    }
14533
14534    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
14535        match resolution {
14536            CommitOutcomeResolution::Active => "active",
14537            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
14538            CommitOutcomeResolution::Superseded => "superseded",
14539            CommitOutcomeResolution::Dismissed => "dismissed",
14540        }
14541    }
14542
14543    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
14544        match value {
14545            "applied" => Ok(CommitOutcomeStatus::Applied),
14546            "cached" => Ok(CommitOutcomeStatus::Cached),
14547            "conflict" => Ok(CommitOutcomeStatus::Conflict),
14548            "rejected" => Ok(CommitOutcomeStatus::Rejected),
14549            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
14550        }
14551    }
14552
14553    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
14554        match value {
14555            "active" => Ok(CommitOutcomeResolution::Active),
14556            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
14557            "superseded" => Ok(CommitOutcomeResolution::Superseded),
14558            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
14559            _ => Err(format!(
14560                "invalid persisted commit outcome resolution {value:?}"
14561            )),
14562        }
14563    }
14564
14565    fn persist_commit_outcome(
14566        &self,
14567        client_commit_id: &str,
14568        status: CommitOutcomeStatus,
14569        results: &[CommitOperationOutcome],
14570        operations: Option<&[OutboxOp]>,
14571    ) -> Result<(), String> {
14572        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
14573        let operations_json = operations
14574            .map(|items| {
14575                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
14576            })
14577            .transpose()
14578            .map_err(|error| error.to_string())?;
14579        self.conn
14580            .execute(
14581                "INSERT INTO _syncular_commit_outcomes (
14582                   client_commit_id, status, recorded_at_ms, results_json,
14583                   operations_json, resolution
14584                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
14585                rusqlite::params![
14586                    client_commit_id,
14587                    Self::outcome_status_name(status),
14588                    self.clock_now_ms(),
14589                    results_json,
14590                    operations_json
14591                ],
14592            )
14593            .map(|_| ())
14594            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14595    }
14596
14597    /// §7.2: the journal holds a stored cell only in its canonical shape. A cell
14598    /// the byte-envelope rule refuses cannot be journaled verbatim.
14599    fn stored_cell_is_representable(cell: &Value) -> bool {
14600        cell.is_null()
14601            || cell.is_string()
14602            || cell.is_boolean()
14603            || cell.is_number()
14604            || is_canonical_bytes_envelope(cell)
14605    }
14606
14607    fn stored_row_is_representable(value: &Value) -> bool {
14608        value
14609            .as_object()
14610            .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
14611    }
14612
14613    fn stored_operation_is_representable(value: &Value) -> bool {
14614        value.get("table").is_some_and(Value::is_string)
14615            && value.get("rowId").is_some_and(Value::is_string)
14616            && value.get("baseVersion").is_none_or(|v| {
14617                v.as_i64()
14618                    .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
14619            })
14620            && match value.get("op").and_then(Value::as_str) {
14621                Some("upsert") => value
14622                    .get("values")
14623                    .is_some_and(Self::stored_row_is_representable),
14624                Some("delete") => value.get("values").is_none(),
14625                _ => false,
14626            }
14627    }
14628
14629    /// §7.2/§7.4.4: a dropped commit keeps its operation envelope in the journal
14630    /// only when the journal reader accepts the serialized operation, which is the
14631    /// rule `outcome_from_row` applies to this same row later.
14632    fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
14633        serde_json::to_value(CommitOperation::from(operation))
14634            .is_ok_and(|value| Self::stored_operation_is_representable(&value))
14635    }
14636
14637    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
14638        let StoredCommitOutcomeRow {
14639            sequence,
14640            client_commit_id,
14641            status,
14642            recorded_at_ms,
14643            results_json,
14644            operations_json,
14645            resolution,
14646            resolved_at_ms,
14647            replacement_client_commit_id,
14648        } = row;
14649        if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
14650            || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
14651            || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
14652        {
14653            return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
14654        }
14655        let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
14656        let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
14657        for result in &stored {
14658            let nested = match result.get("status").and_then(Value::as_str) {
14659                Some("conflict") => result.get("conflict"),
14660                Some("error") => result.get("rejection"),
14661                _ => None,
14662            };
14663            if nested
14664                .and_then(|record| record.get("operation"))
14665                .is_some_and(|op| !Self::stored_operation_is_representable(op))
14666                || (result.get("status").and_then(Value::as_str) == Some("conflict")
14667                    && nested
14668                        .and_then(|record| record.get("serverRow"))
14669                        .is_some_and(|row| !Self::stored_row_is_representable(row)))
14670            {
14671                return Err(invalid());
14672            }
14673        }
14674        let results: Vec<CommitOperationOutcome> =
14675            serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
14676        let has_conflict = results
14677            .iter()
14678            .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
14679        let has_error = results
14680            .iter()
14681            .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
14682        let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
14683        let redacted = results.is_empty() && operations_json.is_none();
14684        if match status {
14685            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
14686            CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
14687            CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
14688        } {
14689            return Err(invalid());
14690        }
14691        let operations: Option<Vec<CommitOperation>> = operations_json
14692            .map(|raw| {
14693                let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
14694                if !stored.iter().all(Self::stored_operation_is_representable) {
14695                    return Err(invalid());
14696                }
14697                serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
14698            })
14699            .transpose()?;
14700        for result in &results {
14701            match result {
14702                CommitOperationOutcome::Conflict { conflict }
14703                    if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
14704                {
14705                    return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
14706                }
14707                CommitOperationOutcome::Error { rejection } => {
14708                    if let Some(details) = &rejection.details {
14709                        let encoded = serde_json::to_string(details).map_err(|_| {
14710                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14711                        })?;
14712                        RejectionDetails::parse(&encoded).map_err(|_| {
14713                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14714                        })?;
14715                    }
14716                }
14717                _ => {}
14718            }
14719        }
14720        Ok(CommitOutcome {
14721            sequence,
14722            client_commit_id,
14723            status,
14724            recorded_at_ms,
14725            results,
14726            operations,
14727            retained_rows: None,
14728            resolution: Self::parse_outcome_resolution(&resolution)?,
14729            resolved_at_ms,
14730            replacement_client_commit_id,
14731        })
14732    }
14733
14734    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
14735    fn unique_conflicts(
14736        &self,
14737        table: &TableSchema,
14738        values: &Map<String, Value>,
14739        base: bool,
14740    ) -> Result<Vec<RetainedUniqueConflict>, String> {
14741        let source = if base {
14742            base_table(&table.name)
14743        } else {
14744            visible_table(&table.name)
14745        };
14746        let mut conflicts = Vec::new();
14747        for index in table.indexes.iter().filter(|index| index.unique) {
14748            let mut params = vec![json_param_to_sql(
14749                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
14750            )?];
14751            for name in &index.columns {
14752                let column = table
14753                    .columns
14754                    .iter()
14755                    .find(|column| column.name == *name)
14756                    .ok_or("sync.local_corrupt")?;
14757                let cell = json_to_column_value(column, values.get(name))?;
14758                params.push(owned_sql_value(RowParam::Cell(&cell))?);
14759            }
14760            let sql = format!(
14761                "SELECT * FROM {source} WHERE {} != ? AND {}",
14762                quote_ident(&table.primary_key),
14763                index
14764                    .columns
14765                    .iter()
14766                    .map(|column| format!("{} = ?", quote_ident(column)))
14767                    .collect::<Vec<_>>()
14768                    .join(" AND ")
14769            );
14770            let mut statement = self
14771                .conn
14772                .prepare_cached(&sql)
14773                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14774            let records = statement
14775                .query_map(rusqlite::params_from_iter(params), |record| {
14776                    let values = table
14777                        .columns
14778                        .iter()
14779                        .enumerate()
14780                        .map(|(index, column)| {
14781                            let value = sql_ref_to_json(column, record.get_ref(index)?);
14782                            json_to_column_value(column, Some(&value)).map_err(|message| {
14783                                rusqlite::Error::ToSqlConversionFailure(message.into())
14784                            })
14785                        })
14786                        .collect::<Result<Row, _>>()?;
14787                    Ok((values, record.get::<_, i64>(table.columns.len())?))
14788                })
14789                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14790            for record in records {
14791                let (row, version) =
14792                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14793                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
14794                conflicts.push(RetainedUniqueConflict {
14795                    index: index.name.clone(),
14796                    columns: index.columns.clone(),
14797                    row_id,
14798                    server_row: table
14799                        .columns
14800                        .iter()
14801                        .zip(&row)
14802                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14803                        .collect(),
14804                    server_version: version,
14805                });
14806            }
14807        }
14808        Ok(conflicts)
14809    }
14810
14811    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
14812        if let Some((commit, initial)) = self
14813            .failed_commits
14814            .iter()
14815            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
14816        {
14817            let mut rows = Vec::new();
14818            for operation in &commit.ops {
14819                let table = self
14820                    .schema
14821                    .table(&operation.table)
14822                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
14823                let key =
14824                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14825                        .map_err(|_| "sync.local_corrupt".to_owned())?;
14826                let base = self.stored_row(table, &operation.row_id, true)?;
14827                rows.push(RetainedCommitRow {
14828                    table: operation.table.clone(),
14829                    row_id: operation.row_id.clone(),
14830                    unique_conflicts: if operation.upsert {
14831                        self.unique_conflicts(
14832                            table,
14833                            initial
14834                                .get(&key)
14835                                .and_then(Value::as_object)
14836                                .or(operation.values.as_ref())
14837                                .ok_or("sync.local_corrupt")?,
14838                            true,
14839                        )?
14840                    } else {
14841                        Vec::new()
14842                    },
14843                    local_row: if operation.upsert {
14844                        initial
14845                            .get(&key)
14846                            .and_then(Value::as_object)
14847                            .cloned()
14848                            .or_else(|| operation.values.clone())
14849                    } else {
14850                        None
14851                    },
14852                    server_row: base.as_ref().map(|(values, _)| {
14853                        table
14854                            .columns
14855                            .iter()
14856                            .zip(values)
14857                            .map(|(column, value)| {
14858                                (column.name.clone(), column_value_to_json(value))
14859                            })
14860                            .collect()
14861                    }),
14862                    server_version: base.map(|(_, version)| version),
14863                });
14864            }
14865            outcome.retained_rows = Some(rows);
14866        }
14867        Ok(outcome)
14868    }
14869
14870    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
14871        let row = self
14872            .conn
14873            .query_row(
14874                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14875                        resolution, resolved_at_ms, replacement_client_commit_id
14876                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
14877                rusqlite::params![client_commit_id],
14878                |row| {
14879                    Ok(StoredCommitOutcomeRow {
14880                        sequence: row.get(0)?,
14881                        client_commit_id: row.get(1)?,
14882                        status: row.get(2)?,
14883                        recorded_at_ms: row.get(3)?,
14884                        results_json: row.get(4)?,
14885                        operations_json: row.get(5)?,
14886                        resolution: row.get(6)?,
14887                        resolved_at_ms: row.get(7)?,
14888                        replacement_client_commit_id: row.get(8)?,
14889                    })
14890                },
14891            )
14892            .optional()
14893            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14894        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
14895            .transpose()
14896    }
14897
14898    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
14899        if query.limit == Some(0) {
14900            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
14901        }
14902        let mut sql = String::from(
14903            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14904                    resolution, resolved_at_ms, replacement_client_commit_id
14905               FROM _syncular_commit_outcomes",
14906        );
14907        if query.active_only {
14908            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
14909        }
14910        sql.push_str(" ORDER BY seq DESC");
14911        if let Some(limit) = query.limit {
14912            sql.push_str(&format!(" LIMIT {limit}"));
14913        }
14914        let mut stmt = self
14915            .conn
14916            .prepare(&sql)
14917            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14918        let rows = stmt
14919            .query_map([], |row| {
14920                Ok(StoredCommitOutcomeRow {
14921                    sequence: row.get(0)?,
14922                    client_commit_id: row.get(1)?,
14923                    status: row.get(2)?,
14924                    recorded_at_ms: row.get(3)?,
14925                    results_json: row.get(4)?,
14926                    operations_json: row.get(5)?,
14927                    resolution: row.get(6)?,
14928                    resolved_at_ms: row.get(7)?,
14929                    replacement_client_commit_id: row.get(8)?,
14930                })
14931            })
14932            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14933        let mut outcomes = Vec::new();
14934        for row in rows {
14935            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
14936                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
14937            )?)?);
14938        }
14939        Ok(outcomes)
14940    }
14941
14942    fn prune_commit_outcomes(&self) -> Result<(), String> {
14943        #[cfg(test)]
14944        self.outcome_prune_count
14945            .set(self.outcome_prune_count.get() + 1);
14946        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
14947        let count = self
14948            .conn
14949            .query_row(
14950                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
14951                [],
14952                |row| row.get::<_, i64>(0),
14953            )
14954            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14955            as usize;
14956        let excess = count.saturating_sub(max_entries);
14957        if excess == 0 {
14958            return Ok(());
14959        }
14960        let mut stmt = self
14961            .conn
14962            .prepare(
14963                "SELECT seq FROM _syncular_commit_outcomes
14964                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
14965                  ORDER BY seq ASC LIMIT ?1",
14966            )
14967            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14968        let rows = stmt
14969            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
14970            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14971        let sequences = rows
14972            .collect::<Result<Vec<_>, _>>()
14973            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14974        drop(stmt);
14975        for sequence in sequences {
14976            self.conn
14977                .execute(
14978                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
14979                    rusqlite::params![sequence],
14980                )
14981                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14982        }
14983        Ok(())
14984    }
14985
14986    // -- driver surface ---------------------------------------------------------
14987
14988    pub fn subscribe(
14989        &mut self,
14990        id: String,
14991        table: String,
14992        scopes: Vec<(String, Vec<String>)>,
14993        params: Option<String>,
14994    ) -> Result<(), String> {
14995        if self.schema.table(&table).is_none() {
14996            return Err(format!("unknown table {table:?}"));
14997        }
14998        let registered_at = self
14999            .subs
15000            .iter()
15001            .position(|subscription| subscription.id == id);
15002        if let Some(index) = registered_at {
15003            let existing = &self.subs[index];
15004            let same_intent = existing.table == table
15005                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
15006                && existing.params == params;
15007            if !same_intent {
15008                return Err(
15009                    "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
15010                        .to_owned(),
15011                );
15012            }
15013            // §3.3: a revoked registration keeps its id and intent but no
15014            // progress. Subscribing again with the same intent re-arms it and
15015            // asks for a pull, which resolves its scopes from scratch. A
15016            // `failed` registration is a configuration error and stays put.
15017            if existing.state != SubState::Revoked {
15018                return Ok(());
15019            }
15020        }
15021        let sub = Subscription {
15022            id: id.clone(),
15023            table,
15024            requested: scopes,
15025            params,
15026            cursor: -1,
15027            bootstrap_state: None,
15028            state: SubState::Active,
15029            reason_code: None,
15030            effective: None,
15031            synced_once: false,
15032        };
15033        self.persist_sub(&sub)?;
15034        match registered_at {
15035            Some(index) => self.subs[index] = sub,
15036            None => self.subs.push(sub),
15037        }
15038        // Only a re-arm asks for a sync here: a fresh registration reaches the
15039        // next round through the host's schedule, and `set_window` (§4.8)
15040        // batches its own intent for the units it registers.
15041        if registered_at.is_some() {
15042            self.set_sync_needed(true, true);
15043        }
15044        Ok(())
15045    }
15046
15047    /// Register several subscriptions in one local transaction: the same
15048    /// rules as `subscribe` for each request, and one commit for all of them
15049    /// (on OPFS a commit costs milliseconds, so a cold start with hundreds of
15050    /// registrations pays it once). An error rolls back every registration
15051    /// of the call.
15052    pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
15053        let prior_subs = self.subs.clone();
15054        self.begin_observation("syncular_subscribe")?;
15055        let registered = (|| {
15056            for request in requests {
15057                self.subscribe(request.id, request.table, request.scopes, request.params)?;
15058            }
15059            self.conn
15060                .execute_batch("RELEASE syncular_subscribe")
15061                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15062        })();
15063        if let Err(error) = registered {
15064            self.rollback_observation("syncular_subscribe");
15065            self.subs = prior_subs;
15066            return Err(error);
15067        }
15068        Ok(())
15069    }
15070
15071    pub fn unsubscribe(&mut self, id: &str) {
15072        if self.subs.iter().any(|s| s.id == id) {
15073            self.cancel_sync_round();
15074        }
15075        self.subs.retain(|s| s.id != id);
15076        let _ = self.conn.execute(
15077            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15078            rusqlite::params![id],
15079        );
15080    }
15081
15082    // -- windowed subscriptions (§4.8) ------------------------------------------
15083
15084    /// §4.8: set the live window units for a base — a value-sharded family
15085    /// of subscriptions, one per unit. Added units get fresh subscriptions
15086    /// (image-lane bootstrap on the next sync); removed units are
15087    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
15088    /// Idempotent; re-entry cancels any deferred eviction.
15089    pub fn set_window(
15090        &mut self,
15091        base: &WindowBase,
15092        units: &[String],
15093    ) -> Result<CommandEffects, String> {
15094        let table = self
15095            .schema
15096            .table(&base.table)
15097            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
15098        if table.scope_column(&base.variable).is_none() {
15099            return Err(format!(
15100                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
15101                base.table, base.variable
15102            ));
15103        }
15104        let base_key = window_base_key(base);
15105        let wanted: std::collections::HashSet<&String> = units.iter().collect();
15106        let live = self.load_window_units(&base_key);
15107        self.begin_observation("syncular_window")?;
15108        let mut batch = ChangeAccumulator::default();
15109        let mut changed = false;
15110
15111        let prior_subs = self.subs.clone();
15112        let widened = (|| {
15113            // Widen: units wanted but not live → fresh subscription + registry row.
15114            for unit in units {
15115                if live.iter().any(|(u, _)| u == unit) {
15116                    continue;
15117                }
15118                let sub_id = derive_sub_id(base, unit);
15119                self.delete_pending_evict(&sub_id)?;
15120                self.insert_window_unit(&base_key, unit, &sub_id)?;
15121                self.subscribe(
15122                    sub_id,
15123                    base.table.clone(),
15124                    unit_scopes(base, unit),
15125                    base.params.clone(),
15126                )?;
15127                batch.window(&base_key, &base.table, unit);
15128                changed = true;
15129            }
15130
15131            self.finish_observation("syncular_window", batch)
15132        })();
15133        if let Err(error) = widened {
15134            self.rollback_observation("syncular_window");
15135            self.subs = prior_subs;
15136            return Err(error);
15137        }
15138        for (unit, sub_id) in live {
15139            if wanted.contains(&unit) {
15140                continue;
15141            }
15142            self.rebuild_overlay_if_dirty()?;
15143            let prior_subs = self.subs.clone();
15144            self.begin_observation("syncular_window")?;
15145            let effective = self
15146                .subs
15147                .iter()
15148                .find(|sub| sub.id == sub_id)
15149                .and_then(|sub| sub.effective.clone())
15150                .unwrap_or_else(|| unit_scopes(base, &unit));
15151            let mut batch = ChangeAccumulator::default();
15152            self.record_scope_map(&mut batch, &base.table, &effective);
15153            batch.window(&base_key, &base.table, &unit);
15154            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
15155            let remaining = match result {
15156                Ok(remaining) => remaining,
15157                Err(error) => {
15158                    self.rollback_observation("syncular_window");
15159                    self.subs = prior_subs;
15160                    return Err(error);
15161                }
15162            };
15163            if let Err(error) = self.finish_observation("syncular_window", batch) {
15164                self.rollback_observation("syncular_window");
15165                self.subs = prior_subs;
15166                return Err(error);
15167            }
15168            if remaining {
15169                self.drain_pending_evictions()?;
15170            }
15171            changed = true;
15172        }
15173        Ok(if changed {
15174            CommandEffects::interactive()
15175        } else {
15176            CommandEffects::none()
15177        })
15178    }
15179
15180    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
15181    /// the subset still bootstrap-pending. Registration alone is not
15182    /// completeness — a unit is pending until its subscription completes a
15183    /// bootstrap round (cursor advances past -1 with no resume token held).
15184    pub fn window_state(&self, base: &WindowBase) -> WindowState {
15185        let mut units = Vec::new();
15186        let mut pending = Vec::new();
15187        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
15188            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
15189                Some(sub) => {
15190                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
15191                }
15192                None => true,
15193            };
15194            if is_pending {
15195                pending.push(unit.clone());
15196            }
15197            units.push(unit);
15198        }
15199        WindowState { units, pending }
15200    }
15201
15202    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
15203        let mut stmt = match self
15204            .conn
15205            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
15206        {
15207            Ok(stmt) => stmt,
15208            Err(_) => return Vec::new(),
15209        };
15210        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
15211            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
15212        });
15213        match rows {
15214            Ok(rows) => rows.filter_map(Result::ok).collect(),
15215            Err(_) => Vec::new(),
15216        }
15217    }
15218
15219    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
15220        let mut stmt = match self.conn.prepare(
15221            "SELECT windows.base, windows.unit, subscriptions.tbl
15222               FROM _syncular_windows AS windows
15223               JOIN _syncular_subscriptions AS subscriptions
15224                 ON subscriptions.id = windows.sub_id
15225               ORDER BY windows.base, windows.unit",
15226        ) {
15227            Ok(stmt) => stmt,
15228            Err(_) => return Vec::new(),
15229        };
15230        let rows = stmt.query_map([], |row| {
15231            Ok((
15232                row.get::<_, String>(0)?,
15233                row.get::<_, String>(1)?,
15234                row.get::<_, String>(2)?,
15235            ))
15236        });
15237        match rows {
15238            Ok(rows) => rows.filter_map(Result::ok).collect(),
15239            Err(_) => Vec::new(),
15240        }
15241    }
15242
15243    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
15244        self.conn
15245            .query_row(
15246                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
15247                rusqlite::params![sub_id],
15248                |row| Ok((row.get(0)?, row.get(1)?)),
15249            )
15250            .ok()
15251    }
15252
15253    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
15254        self.conn
15255            .execute(
15256                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
15257                rusqlite::params![base_key, unit, sub_id],
15258            )
15259            .map(|_| ())
15260            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15261    }
15262
15263    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
15264        self.conn
15265            .execute(
15266                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
15267                rusqlite::params![base_key, unit],
15268            )
15269            .map(|_| ())
15270            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15271    }
15272
15273    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
15274    /// Deletes the unit's rows except those pinned by a pending outbox
15275    /// commit (E1); records a deferred eviction if any pin remains; discards
15276    /// the subscription's cursor/resume/effective-echo (E3) and version
15277    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
15278    /// nothing.
15279    fn evict_unit(
15280        &mut self,
15281        base_key: &str,
15282        base: &WindowBase,
15283        unit: &str,
15284        sub_id: &str,
15285    ) -> Result<bool, String> {
15286        let effective = self
15287            .subs
15288            .iter()
15289            .find(|s| s.id == sub_id)
15290            .and_then(|s| s.effective.clone())
15291            .unwrap_or_else(|| unit_scopes(base, unit));
15292        let pinned = self.pinned_row_ids(&base.table);
15293        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
15294        self.delete_window_unit(base_key, unit)?;
15295        self.conn
15296            .execute(
15297                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15298                [sub_id],
15299            )
15300            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15301        self.subs.retain(|sub| sub.id != sub_id);
15302        if deferred {
15303            self.save_pending_evict(sub_id, &base.table, &effective)?;
15304        } else {
15305            self.delete_pending_evict(sub_id)?;
15306        }
15307        Ok(remaining)
15308    }
15309
15310    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
15311    /// primary keys. Returns (more unpinned work, any scoped rows remain).
15312    /// A missing scope-column mapping fails without deleting rows.
15313    fn evict_scope_rows(
15314        &self,
15315        table_name: &str,
15316        effective: &[(String, Vec<String>)],
15317        pinned: &std::collections::HashSet<String>,
15318    ) -> Result<(bool, bool), String> {
15319        if effective.is_empty() {
15320            return Ok((false, false));
15321        }
15322        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
15323        let mut clauses = Vec::new();
15324        let mut params: Vec<SqlValue> = Vec::new();
15325        for (variable, values) in effective {
15326            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
15327            if values.is_empty() {
15328                return Ok((false, false));
15329            }
15330            let holes = values
15331                .iter()
15332                .map(|v| {
15333                    params.push(SqlValue::Text(v.clone()));
15334                    "?"
15335                })
15336                .collect::<Vec<_>>()
15337                .join(", ");
15338            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
15339        }
15340        let scope = clauses.join(" AND ");
15341        let scope_params = params.len();
15342        let pk = quote_ident(&table.primary_key);
15343        if !pinned.is_empty() {
15344            let holes = pinned
15345                .iter()
15346                .map(|id| {
15347                    params.push(SqlValue::Text(id.clone()));
15348                    "?"
15349                })
15350                .collect::<Vec<_>>()
15351                .join(", ");
15352            clauses.push(format!("{pk} NOT IN ({holes})"));
15353        }
15354        let predicate = clauses.join(" AND ");
15355        let base = base_table(table_name);
15356        let visible = visible_table(table_name);
15357        let mut ids: Vec<SqlValue> = Vec::new();
15358        for source in [&base, &visible] {
15359            let mut stmt = self
15360                .conn
15361                .prepare_cached(&format!(
15362                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
15363                ))
15364                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15365            ids = stmt
15366                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
15367                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15368                .collect::<Result<_, _>>()
15369                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15370            if !ids.is_empty() {
15371                break;
15372            }
15373        }
15374        if !ids.is_empty() {
15375            for id in &ids {
15376                self.conn
15377                    .execute(
15378                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
15379                        rusqlite::params![table_name, id],
15380                    )
15381                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15382            }
15383            let holes = vec!["?"; ids.len()].join(", ");
15384            for source in [&base, &visible] {
15385                self.conn
15386                    .execute(
15387                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
15388                        rusqlite::params_from_iter(&ids),
15389                    )
15390                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15391            }
15392        }
15393        let mut remaining = false;
15394        let mut deferred = false;
15395        for source in [&base, &visible] {
15396            remaining |= self
15397                .conn
15398                .query_row(
15399                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
15400                    rusqlite::params_from_iter(&params),
15401                    |row| row.get::<_, bool>(0),
15402                )
15403                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15404            deferred |= self
15405                .conn
15406                .query_row(
15407                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
15408                    rusqlite::params_from_iter(&params[..scope_params]),
15409                    |row| row.get::<_, bool>(0),
15410                )
15411                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15412        }
15413        Ok((remaining, deferred))
15414    }
15415
15416    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
15417    fn drain_pending_evictions(&mut self) -> Result<(), String> {
15418        let pending = self.load_pending_evictions()?;
15419        if pending.is_empty() {
15420            return Ok(());
15421        }
15422        self.rebuild_overlay_if_dirty()?;
15423        for (sub_id, table_name, effective) in pending {
15424            if self.schema.table(&table_name).is_none() {
15425                self.delete_pending_evict(&sub_id)?;
15426                continue;
15427            }
15428            loop {
15429                std::thread::yield_now();
15430                self.begin_observation("syncular_evict")?;
15431                let pinned = self.pinned_row_ids(&table_name);
15432                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
15433                let (remaining, deferred) = match result {
15434                    Ok(result) => result,
15435                    Err(error) => {
15436                        self.rollback_observation("syncular_evict");
15437                        return Err(error);
15438                    }
15439                };
15440                if !deferred {
15441                    if let Err(error) = self.delete_pending_evict(&sub_id) {
15442                        self.rollback_observation("syncular_evict");
15443                        return Err(error);
15444                    }
15445                }
15446                let mut batch = ChangeAccumulator::default();
15447                self.record_scope_map(&mut batch, &table_name, &effective);
15448                if let Err(error) = self.finish_observation("syncular_evict", batch) {
15449                    self.rollback_observation("syncular_evict");
15450                    return Err(error);
15451                }
15452                if !remaining {
15453                    break;
15454                }
15455            }
15456        }
15457        Ok(())
15458    }
15459
15460    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
15461    /// commit — rows that MUST NOT be evicted until the commit drains.
15462    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
15463        let mut pinned = std::collections::HashSet::new();
15464        for commit in self
15465            .outbox
15466            .iter()
15467            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
15468        {
15469            for op in &commit.ops {
15470                if op.table == table {
15471                    pinned.insert(op.row_id.clone());
15472                }
15473            }
15474        }
15475        pinned
15476    }
15477
15478    fn save_pending_evict(
15479        &self,
15480        sub_id: &str,
15481        table: &str,
15482        effective: &[(String, Vec<String>)],
15483    ) -> Result<(), String> {
15484        self.conn.execute(
15485            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
15486            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
15487        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15488    }
15489
15490    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
15491        self.conn
15492            .execute(
15493                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
15494                rusqlite::params![sub_id],
15495            )
15496            .map(|_| ())
15497            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15498    }
15499
15500    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
15501        let mut stmt = self
15502            .conn
15503            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
15504            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15505        let rows = stmt
15506            .query_map([], |row| {
15507                Ok((
15508                    row.get::<_, String>(0)?,
15509                    row.get::<_, String>(1)?,
15510                    row.get::<_, String>(2)?,
15511                ))
15512            })
15513            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15514        rows.map(|row| {
15515            let (sub_id, table, json) =
15516                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15517            let value: Value = serde_json::from_str(&json)
15518                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15519            let effective = json_to_scope_map(&value)
15520                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15521            Ok((sub_id, table, effective))
15522        })
15523        .collect()
15524    }
15525
15526    /// Select failed-intent retention; reload durable retained aggregates.
15527    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
15528        self.retain_failed_commits = enabled;
15529        for (commit, _) in &self.failed_commits {
15530            for operation in &commit.ops {
15531                self.overlay_dirty.table(&operation.table);
15532            }
15533        }
15534        self.failed_commits = self.load_failed_commits()?;
15535        for (commit, _) in &self.failed_commits {
15536            for operation in &commit.ops {
15537                self.overlay_dirty.table(&operation.table);
15538            }
15539        }
15540        Ok(())
15541    }
15542
15543    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
15544        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
15545            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15546        let rows = statement
15547            .query_map([], |row| {
15548                Ok((
15549                    row.get::<_, String>(0)?,
15550                    row.get::<_, String>(1)?,
15551                    row.get::<_, String>(2)?,
15552                ))
15553            })
15554            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15555        rows.map(|row| {
15556            let (id, operations, initial) =
15557                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15558            let operations: Vec<CommitOperation> =
15559                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
15560            let initial =
15561                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
15562            Ok((
15563                OutboxCommit {
15564                    client_commit_id: id,
15565                    ops: operations
15566                        .into_iter()
15567                        .map(|op| OutboxOp {
15568                            upsert: op.op == "upsert",
15569                            table: op.table,
15570                            row_id: op.row_id,
15571                            base_version: op.base_version,
15572                            values: op.values,
15573                        })
15574                        .collect(),
15575                },
15576                initial,
15577            ))
15578        })
15579        .collect()
15580    }
15581
15582    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
15583        let mut initial = Map::new();
15584        for operation in operations {
15585            let table = self
15586                .schema
15587                .table(&operation.table)
15588                .ok_or_else(|| "sync.unknown_table".to_owned())?;
15589            let initial_row = match self.stored_row(table, &operation.row_id, true)? {
15590                Some(row) => Some(row),
15591                None => self.visible_row(table, &operation.row_id)?,
15592            };
15593            if let Some((values, _)) = initial_row {
15594                let mut intended: Map<String, Value> = table
15595                    .columns
15596                    .iter()
15597                    .zip(values.iter())
15598                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
15599                    .collect();
15600                if let Some(patch) = &operation.values {
15601                    intended.extend(patch.clone());
15602                }
15603                initial.insert(
15604                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15605                        .map_err(|_| "sync.local_corrupt".to_owned())?,
15606                    Value::Object(intended),
15607                );
15608            }
15609        }
15610        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
15611            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
15612            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
15613            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15614        self.failed_commits.push((
15615            OutboxCommit {
15616                client_commit_id: id.to_owned(),
15617                ops: operations.to_vec(),
15618            },
15619            initial,
15620        ));
15621        Ok(())
15622    }
15623
15624    fn drop_failed_commits(
15625        &mut self,
15626        matches: impl Fn(&str, &Map<String, Value>) -> bool,
15627    ) -> Result<Vec<OutboxCommit>, String> {
15628        let mut doomed = self.drop_acknowledged_rows(&matches)?;
15629        for (commit, initial) in &self.failed_commits {
15630            for operation in &commit.ops {
15631                let key =
15632                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15633                        .map_err(|_| "sync.local_corrupt".to_owned())?;
15634                if initial
15635                    .get(&key)
15636                    .and_then(Value::as_object)
15637                    .is_some_and(|values| matches(&operation.table, values))
15638                {
15639                    doomed.push(commit.clone());
15640                    break;
15641                }
15642            }
15643        }
15644        for commit in &doomed {
15645            self.conn
15646                .execute(
15647                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15648                    rusqlite::params![commit.client_commit_id],
15649                )
15650                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15651        }
15652        for commit in &doomed {
15653            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))?;
15654        }
15655        self.conflicts.retain(|record| {
15656            !doomed
15657                .iter()
15658                .any(|commit| commit.client_commit_id == record.client_commit_id)
15659        });
15660        self.rejections.retain(|record| {
15661            !doomed
15662                .iter()
15663                .any(|commit| commit.client_commit_id == record.client_commit_id)
15664        });
15665        self.failed_commits.retain(|(commit, _)| {
15666            !doomed
15667                .iter()
15668                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
15669        });
15670        for commit in &doomed {
15671            for operation in &commit.ops {
15672                self.overlay_dirty.table(&operation.table);
15673            }
15674        }
15675        Ok(doomed)
15676    }
15677
15678    /// Record one commit of local mutations. The returned commit ID is the
15679    /// §2.3 idempotency key. Failures are structured so callers read
15680    /// `code`/`message`/`details` instead of parsing a string prefix.
15681    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
15682        let prior = self.storage_failure.borrow_mut().take();
15683        let outcome = (|| -> Result<String, String> {
15684            if mutations.is_empty() {
15685                return Err(
15686                    "sync.invalid_request: a commit must contain at least one operation (§6.1)"
15687                        .to_owned(),
15688                );
15689            }
15690            let mut ops = Vec::with_capacity(mutations.len());
15691            for mutation in mutations {
15692                match mutation {
15693                    Mutation::Patch {
15694                        table,
15695                        values,
15696                        base_version,
15697                    } => {
15698                        let schema_table = self
15699                            .schema
15700                            .table(&table)
15701                            .ok_or_else(|| "sync.unknown_table".to_owned())?;
15702                        let values = normalize_values_casing(schema_table, values)?;
15703                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15704                        ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
15705                    }
15706                    Mutation::Upsert {
15707                        table,
15708                        values,
15709                        base_version,
15710                    } => {
15711                        let schema_table = self.schema.table(&table).ok_or_else(|| {
15712                            format!("sync.unknown_table: unknown table {table:?}")
15713                        })?;
15714                        // §5: value keys are accepted in snake_case AND the
15715                        // generated row types' camelCase; normalize to SQL truth
15716                        // before the pk lookup / codec see them.
15717                        let values = normalize_values_casing(schema_table, values)?;
15718                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15719                        // §6.7 `mutate` marks every column present: fill missing
15720                        // nullable columns with NULL before the payload is built.
15721                        let values = full_row_values(schema_table, values)?;
15722                        // §5.11: validate the payload encodes with the current
15723                        // codec. A key-selection or unknown-key failure is a
15724                        // durable rejection at the push seam (§10.3), never an
15725                        // author-time error. Full-row `mutate` presents every
15726                        // column, so no stored fallback is needed.
15727                        self.validate_author_encode(schema_table, &row_id, &values)?;
15728                        ops.push(OutboxOp {
15729                            upsert: true,
15730                            table,
15731                            row_id,
15732                            base_version,
15733                            values: Some(values),
15734                        });
15735                    }
15736                    Mutation::Delete {
15737                        table,
15738                        row_id,
15739                        base_version,
15740                    } => {
15741                        if self.schema.table(&table).is_none() {
15742                            return Err(format!("sync.unknown_table: unknown table {table:?}"));
15743                        }
15744                        ops.push(OutboxOp {
15745                            upsert: false,
15746                            table,
15747                            row_id,
15748                            base_version,
15749                            values: None,
15750                        });
15751                    }
15752                }
15753            }
15754            self.record_outbox_commit(ops)
15755        })();
15756        self.finish_authoring(prior, outcome)
15757    }
15758
15759    /// Scope the retained `storage_failure` diagnostic to this call so an
15760    /// earlier operation's failure cannot classify a new one, and surface the
15761    /// call's own classified failure with its details. A fresh classified
15762    /// failure replaces the retained diagnostic; otherwise the earlier one is
15763    /// kept for the next sync.
15764    fn finish_authoring(
15765        &self,
15766        prior: Option<QueryReadFailure>,
15767        outcome: Result<String, String>,
15768    ) -> Result<String, ClientError> {
15769        let fresh = self.storage_failure.borrow_mut().take();
15770        *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
15771        match outcome {
15772            Ok(id) => Ok(id),
15773            Err(message) => Err(fresh
15774                .map(ClientError::from)
15775                .unwrap_or_else(|| ClientError::from(message))),
15776        }
15777    }
15778
15779    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
15780        let commit = OutboxCommit {
15781            client_commit_id: uuid::Uuid::new_v4().to_string(),
15782            ops,
15783        };
15784        self.begin_observation("syncular_mutation")?;
15785        let mut batch = ChangeAccumulator::default();
15786        for op in &commit.ops {
15787            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
15788            if let Some(values) = &op.values {
15789                if let Some(table) = self.schema.table(&op.table) {
15790                    for scope in &table.scope_variables {
15791                        if let Some(Value::String(value)) = values.get(&scope.column) {
15792                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
15793                            precise = true;
15794                        }
15795                    }
15796                }
15797            }
15798            if !precise {
15799                batch.table(&op.table);
15800            }
15801        }
15802        if let Err(error) = self.persist_outbox_insert(&commit) {
15803            self.rollback_observation("syncular_mutation");
15804            return Err(error);
15805        }
15806        let was_dirty = self.overlay_dirty.snapshot();
15807        // A clean overlay contains the FIFO fold of every previous commit, so
15808        // the new commit applies over it. §7.1: an operation that does not
15809        // apply (a secondary unique collision) fails the whole commit; the
15810        // savepoint rollback removes the outbox entry and every visible write.
15811        if let Err(error) = self.rebuild_overlay_if_dirty() {
15812            self.rollback_observation("syncular_mutation");
15813            self.overlay_dirty.restore(was_dirty.clone());
15814            return Err(error);
15815        }
15816        if let Some(error) = commit
15817            .ops
15818            .iter()
15819            .find_map(|op| self.apply_outbox_op(op).err())
15820        {
15821            self.rollback_observation("syncular_mutation");
15822            self.overlay_dirty.restore(was_dirty.clone());
15823            return Err(error);
15824        }
15825        let id = commit.client_commit_id.clone();
15826        self.outbox.push(commit);
15827        batch.status = true;
15828        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
15829            self.rollback_observation("syncular_mutation");
15830            self.outbox.pop();
15831            self.overlay_dirty.restore(was_dirty.clone());
15832            return Err(error);
15833        }
15834        Ok(id)
15835    }
15836
15837    /// Record one §6.1 sparse upsert: the primary key plus the supplied
15838    /// non-scope columns are present; every other column is absent and stays
15839    /// untouched on the server and in the local overlay. An absent local base
15840    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
15841    /// Failures are structured so callers read `code`/`message`/`details`.
15842    pub fn patch(
15843        &mut self,
15844        table: &str,
15845        row_id: &str,
15846        partial: Map<String, Value>,
15847        base_version: Option<i64>,
15848    ) -> Result<String, ClientError> {
15849        let prior = self.storage_failure.borrow_mut().take();
15850        let outcome = self.perform_patch(table, row_id, partial, base_version);
15851        self.finish_authoring(prior, outcome)
15852    }
15853
15854    /// The unscoped patch body. Public authoring entry points scope
15855    /// `storage_failure` around the whole operation, including any pre-read.
15856    fn perform_patch(
15857        &mut self,
15858        table: &str,
15859        row_id: &str,
15860        partial: Map<String, Value>,
15861        base_version: Option<i64>,
15862    ) -> Result<String, String> {
15863        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
15864        self.record_outbox_commit(vec![operation])
15865    }
15866
15867    fn prepare_patch(
15868        &self,
15869        table: &str,
15870        row_id: &str,
15871        partial: Map<String, Value>,
15872        base_version: Option<i64>,
15873    ) -> Result<OutboxOp, String> {
15874        let schema_table = self
15875            .schema
15876            .table(table)
15877            .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
15878        let partial = normalize_values_casing(schema_table, partial)?;
15879        if let Some(pk) = partial.get(&schema_table.primary_key) {
15880            if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
15881                return Err(format!(
15882                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
15883                ));
15884            }
15885        }
15886        // Existence reads only the key. Locked encrypted values stay opaque;
15887        // decoding the complete row here would reject a valid plain patch.
15888        let exists: bool = self
15889            .conn
15890            .query_row(
15891                &format!(
15892                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
15893                    visible_table(table),
15894                    row_id_predicate(schema_table)
15895                ),
15896                rusqlite::params![row_id],
15897                |row| row.get(0),
15898            )
15899            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15900        if !exists {
15901            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
15902        }
15903        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
15904        // server accepts a present scope column whose value equals the
15905        // stored row and applies it as a no-op, so a decoded envelope
15906        // round-trips. The local row is the only value the client can prove
15907        // equality against; when the row is absent locally the client cannot
15908        // prove equality against the server's stored row and fails closed.
15909        // A primary key that is also a scope column needs no stored-row
15910        // read: the primary key in a sparse payload is by construction the
15911        // row id being patched (§6.1), so its value is proven equal already.
15912        for scope in &schema_table.scope_variables {
15913            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
15914                continue;
15915            }
15916            let Some(column) = schema_table
15917                .columns
15918                .iter()
15919                .find(|column| column.name == scope.column)
15920            else {
15921                continue;
15922            };
15923            let stored = self
15924                .conn
15925                .query_row(
15926                    &format!(
15927                        "SELECT {} FROM {} WHERE {}",
15928                        quote_ident(&scope.column),
15929                        visible_table(&schema_table.name),
15930                        row_id_predicate(schema_table)
15931                    ),
15932                    rusqlite::params![row_id],
15933                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
15934                )
15935                .optional()
15936                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15937            // Decode the supplied value through the same column-type path the
15938            // rest of the patch encoding uses, so a value that only equals
15939            // the stored one in another number representation still compares
15940            // equal.
15941            let incoming = json_to_column_value(column, partial.get(&scope.column))
15942                .ok()
15943                .map(|value| column_value_to_json(&value));
15944            // A stored blob never equals a patch value, matching the server's
15945            // scope-column comparison (§3.4): bytes are unstorable as scope
15946            // values.
15947            let equal = stored.as_ref().is_some_and(|stored| {
15948                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
15949            });
15950            if !equal {
15951                return Err(format!(
15952                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
15953                    scope.column
15954                ));
15955            }
15956        }
15957        let mut values = partial;
15958        // Presence-set semantics: a proven-equal scope column is dropped, so
15959        // the patch leaves the stored value untouched (§6.2).
15960        for scope in &schema_table.scope_variables {
15961            values.remove(&scope.column);
15962        }
15963        let pk_value = match schema_table.columns[schema_table.pk_index].ty {
15964            // §2.4: the rowId of an integer key is its canonical decimal form.
15965            ColumnType::Integer => match row_id.parse::<i64>() {
15966                Ok(v)
15967                    if v.to_string() == row_id
15968                        && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
15969                            .contains(&v) =>
15970                {
15971                    Value::from(v)
15972                }
15973                _ => {
15974                    return Err(format!(
15975                    "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
15976                ))
15977                }
15978            },
15979            ColumnType::Boolean => match row_id {
15980                "true" => Value::Bool(true),
15981                "false" => Value::Bool(false),
15982                _ => {
15983                    return Err(format!(
15984                    "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
15985                ))
15986                }
15987            },
15988            _ => Value::from(row_id.to_owned()),
15989        };
15990        values.insert(schema_table.primary_key.clone(), pk_value);
15991        // §5.11: structural validation only; an absent key-id selector is
15992        // resolved at the push seam from the stored local row, and a
15993        // key-selection or unknown-key failure becomes a durable
15994        // `client.encrypt_failed` rejection there (§10.3), never an
15995        // author-time error.
15996        self.validate_author_encode(schema_table, row_id, &values)?;
15997        Ok(OutboxOp {
15998            upsert: true,
15999            table: table.to_owned(),
16000            row_id: row_id.to_owned(),
16001            base_version,
16002            values: Some(values),
16003        })
16004    }
16005
16006    /// §5.11 key-id fallback: the stored local row's selector value as a
16007    /// positional row (only the selector slot filled). Returns None when no
16008    /// selector is configured, the row is locally absent, or the stored
16009    /// value is not a usable key id.
16010    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
16011        let selector = self.encryption.key_id_columns.get(&table.name)?;
16012        let index = table
16013            .columns
16014            .iter()
16015            .position(|column| &column.name == selector)?;
16016        let sql = format!(
16017            "SELECT {} FROM {} WHERE {}",
16018            quote_ident(selector),
16019            visible_table(&table.name),
16020            row_id_predicate(table)
16021        );
16022        let key: Option<String> = self
16023            .conn
16024            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
16025            .ok()?;
16026        let key = key.filter(|key| !key.is_empty())?;
16027        let mut fallback: Row = vec![None; table.columns.len()];
16028        fallback[index] = Some(ColumnValue::String(key));
16029        Some(fallback)
16030    }
16031
16032    /// §5.11/§10.3 author-time encode check: a structural failure (a
16033    /// non-nullable NULL, a value the codec rejects) is an author-time
16034    /// error; a key-selection or unknown-key failure is deferred to the push
16035    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
16036    fn validate_author_encode(
16037        &self,
16038        table: &TableSchema,
16039        row_id: &str,
16040        values: &Map<String, Value>,
16041    ) -> Result<(), String> {
16042        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
16043            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
16044            result => result.map(|_| ()),
16045        }
16046    }
16047
16048    /// §5.11/§10.3: drop every pending commit whose push encode fails with
16049    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
16050    /// raise one durable rejection per commit. `sync()` never aborts for an
16051    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
16052    /// undoing the dropped commit's purely-optimistic rows.
16053    /// §7.1/§7.4.4: drop pending commits whose values the current codec refuses
16054    /// (an envelope or a type an earlier version accepted). `with_encryption`
16055    /// additionally drops commits whose §5.11 encryption cannot resolve a key;
16056    /// the startup boundary leaves those to the send seam, where hosts configure
16057    /// keys after open.
16058    /// Recovery runs as one observation. An enclosing observation (the guarded
16059    /// schema reset) passes its `batch`, so the rejection, outcome, and table
16060    /// domains join that change event instead of publishing a second one.
16061    fn drop_unencodable_outbox(
16062        &mut self,
16063        with_encryption: bool,
16064        batch: Option<&mut ChangeAccumulator>,
16065    ) -> Result<bool, String> {
16066        let failures = self
16067            .outbox
16068            .iter()
16069            .filter_map(|commit| {
16070                commit
16071                    .ops
16072                    .iter()
16073                    .find_map(|op| {
16074                        let values = op.values.as_ref().filter(|_| op.upsert)?;
16075                        let table = self.schema.table(&op.table)?;
16076                        let fallback = self.stored_key_fallback(table, &op.row_id);
16077                        match encode_sparse_row_json(
16078                            table,
16079                            &op.row_id,
16080                            values,
16081                            &self.encryption,
16082                            fallback.as_ref(),
16083                        ) {
16084                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
16085                                with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
16086                            }
16087                            // §7.1/§7.4.4: a value the current codec refuses was
16088                            // persisted by a version that accepted it; it leaves
16089                            // through the incompatible-outbox path, so the FIFO
16090                            // queue never wedges.
16091                            Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
16092                                LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
16093                                OUTBOX_INCOMPATIBLE_CODE,
16094                            )),
16095                            _ => None,
16096                        }
16097                    })
16098                    .map(|(error, code)| (commit.clone(), error, code))
16099            })
16100            .collect::<Vec<_>>();
16101        if failures.is_empty() {
16102            return Ok(false);
16103        }
16104        // §7.4.4: one observation covers the durable outcome writes, the outbox
16105        // removal, the in-memory drop, and the overlay rebuild, so a storage fault
16106        // leaves the replica and the client unchanged, a successful recovery
16107        // publishes one batch with the rejection, outcome, and table domains, and
16108        // an ordinary open with nothing to recover publishes nothing.
16109        let prior_outbox = self.outbox.clone();
16110        let prior_rejections = self.rejections.clone();
16111        // §5.11 retention and a self-published observation mutate two more
16112        // in-memory fields before this transaction ends, so the rollback below
16113        // restores them with the rest.
16114        let prior_failed_commits = self.failed_commits.clone();
16115        let prior_changes = self.change_queue.len();
16116        let prior_intents = self.sync_intent_queue.len();
16117        let prior_last_change = self.last_change.clone();
16118        let prior_overlay_dirty = self.overlay_dirty.snapshot();
16119        let owns_observation = batch.is_none();
16120        let owns_transaction = self.conn.is_autocommit();
16121        let mut domains = ChangeAccumulator::default();
16122        if owns_observation {
16123            self.begin_observation("syncular_value_recovery")?;
16124        }
16125        let dropped = (|| -> Result<(), String> {
16126            for (commit, message, code) in failures {
16127                if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
16128                    self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
16129                }
16130                let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
16131                let rejection = RejectionRecord {
16132                    client_commit_id: commit.client_commit_id.clone(),
16133                    op_index: 0,
16134                    code: code.to_owned(),
16135                    message,
16136                    retryable: false,
16137                    details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
16138                        field_paths: None,
16139                        reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
16140                        required_action: None,
16141                        references: None,
16142                    }),
16143                    operation: representable
16144                        .then(|| commit.ops.first())
16145                        .flatten()
16146                        .map(CommitOperation::from),
16147                };
16148                self.persist_commit_outcome(
16149                    &commit.client_commit_id,
16150                    CommitOutcomeStatus::Rejected,
16151                    &[CommitOperationOutcome::Error {
16152                        rejection: rejection.clone(),
16153                    }],
16154                    representable.then_some(commit.ops.as_slice()),
16155                )?;
16156                self.delete_outbox_persisted(&commit.client_commit_id)?;
16157                self.outbox
16158                    .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
16159                self.rejections.push(rejection);
16160                for operation in &commit.ops {
16161                    domains.table(operation.table.as_str());
16162                }
16163            }
16164            domains.status = true;
16165            domains.rejections = true;
16166            domains.outcomes = true;
16167            self.prune_commit_outcomes()?;
16168            // The visible projection is re-derived from the surviving base + outbox.
16169            self.overlay_dirty.set(true);
16170            self.rebuild_overlay()?;
16171            Ok(())
16172        })();
16173        let result = match batch {
16174            Some(batch) => {
16175                if dropped.is_ok() {
16176                    for table in domains.tables.keys() {
16177                        batch.table(table.as_str());
16178                    }
16179                    batch.status |= domains.status;
16180                    batch.rejections |= domains.rejections;
16181                    batch.outcomes |= domains.outcomes;
16182                }
16183                dropped
16184            }
16185            None => {
16186                dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
16187            }
16188        };
16189        if let Err(error) = result {
16190            if owns_observation {
16191                if owns_transaction {
16192                    if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
16193                        let mut failure = self.storage_failure.borrow_mut();
16194                        let failure =
16195                            failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
16196                        failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
16197                    }
16198                } else {
16199                    self.rollback_observation("syncular_value_recovery");
16200                }
16201            }
16202            self.outbox = prior_outbox;
16203            self.rejections = prior_rejections;
16204            self.failed_commits = prior_failed_commits;
16205            self.change_queue.truncate(prior_changes);
16206            self.sync_intent_queue.truncate(prior_intents);
16207            self.last_change = prior_last_change;
16208            self.overlay_dirty.restore(prior_overlay_dirty);
16209            return Err(error);
16210        }
16211        Ok(true)
16212    }
16213
16214    pub fn pending_commit_ids(&self) -> Vec<String> {
16215        self.outbox
16216            .iter()
16217            .map(|c| c.client_commit_id.clone())
16218            .collect()
16219    }
16220
16221    /// §6.1: the sparse push payload bytes of every pending upsert operation,
16222    /// FIFO — exactly what the next push round would put on the wire. Used by
16223    /// conformance B.21(h) to pin cross-core byte identity.
16224    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
16225        let mut payloads = Vec::new();
16226        for commit in &self.outbox {
16227            for op in &commit.ops {
16228                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
16229                    continue;
16230                };
16231                let Some(table) = self.schema.table(&op.table) else {
16232                    continue;
16233                };
16234                let fallback = self.stored_key_fallback(table, &op.row_id);
16235                if let Ok(payload) = encode_sparse_row_json(
16236                    table,
16237                    &op.row_id,
16238                    values,
16239                    &self.encryption,
16240                    fallback.as_ref(),
16241                ) {
16242                    payloads.push(payload);
16243                }
16244            }
16245        }
16246        payloads
16247    }
16248
16249    pub fn conflicts(&self) -> &[ConflictRecord] {
16250        &self.conflicts
16251    }
16252
16253    pub fn rejections(&self) -> &[RejectionRecord] {
16254        &self.rejections
16255    }
16256
16257    pub fn resolve_commit_outcome(
16258        &mut self,
16259        input: ResolveCommitOutcomeInput,
16260    ) -> Result<CommitOutcome, String> {
16261        let current = self
16262            .commit_outcome(&input.client_commit_id)?
16263            .ok_or_else(|| {
16264                format!(
16265                    "sync.outcome_not_found: no durable outcome exists for {:?}",
16266                    input.client_commit_id
16267                )
16268            })?;
16269        if current.resolution != CommitOutcomeResolution::Active {
16270            return Ok(current);
16271        }
16272        if input.resolution == CommitOutcomeResolution::Active {
16273            return Err("sync.invalid_request: resolution must leave active state".to_owned());
16274        }
16275        match input.resolution {
16276            CommitOutcomeResolution::Superseded => {
16277                let replacement = input
16278                    .replacement_client_commit_id
16279                    .as_deref()
16280                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
16281                    .ok_or_else(|| {
16282                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
16283                            .to_owned()
16284                    })?;
16285                let _ = replacement;
16286            }
16287            _ if input.replacement_client_commit_id.is_some() => {
16288                return Err(
16289                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
16290                        .to_owned(),
16291                );
16292            }
16293            _ => {}
16294        }
16295        let allowed = match current.status {
16296            CommitOutcomeStatus::Conflict => matches!(
16297                input.resolution,
16298                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
16299            ),
16300            CommitOutcomeStatus::Rejected => {
16301                matches!(
16302                    input.resolution,
16303                    CommitOutcomeResolution::Superseded
16304                        | CommitOutcomeResolution::ResolvedKeepServer
16305                )
16306            }
16307            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
16308                input.resolution == CommitOutcomeResolution::Dismissed
16309            }
16310        };
16311        if !allowed {
16312            return Err(format!(
16313                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
16314                input.resolution, current.status
16315            ));
16316        }
16317
16318        self.begin_observation("syncular_outcome_resolution")?;
16319        let previous_failed = self.failed_commits.clone();
16320        let previous_conflicts = self.conflicts.clone();
16321        let previous_rejections = self.rejections.clone();
16322        let result = (|| {
16323            self.conn
16324                .execute(
16325                    "UPDATE _syncular_commit_outcomes
16326                        SET resolution = ?1, resolved_at_ms = ?2,
16327                            replacement_client_commit_id = ?3
16328                      WHERE client_commit_id = ?4 AND resolution = 'active'",
16329                    rusqlite::params![
16330                        Self::outcome_resolution_name(input.resolution),
16331                        self.clock_now_ms(),
16332                        input.replacement_client_commit_id,
16333                        input.client_commit_id
16334                    ],
16335                )
16336                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16337            let resolved = self
16338                .commit_outcome(&current.client_commit_id)?
16339                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
16340            self.conn
16341                .execute(
16342                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
16343                    rusqlite::params![input.client_commit_id],
16344                )
16345                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16346            for (commit, _) in &self.failed_commits {
16347                if commit.client_commit_id == input.client_commit_id {
16348                    for operation in &commit.ops {
16349                        self.overlay_dirty.table(&operation.table);
16350                    }
16351                }
16352            }
16353            self.failed_commits
16354                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
16355            self.prune_commit_outcomes()?;
16356            Ok(resolved)
16357        })();
16358        let resolved = match result {
16359            Ok(outcome) => outcome,
16360            Err(error) => {
16361                self.rollback_observation("syncular_outcome_resolution");
16362                self.failed_commits = previous_failed;
16363                self.conflicts = previous_conflicts;
16364                self.rejections = previous_rejections;
16365                return Err(error);
16366            }
16367        };
16368        self.conflicts
16369            .retain(|record| record.client_commit_id != current.client_commit_id);
16370        self.rejections
16371            .retain(|record| record.client_commit_id != current.client_commit_id);
16372        let mut batch = ChangeAccumulator {
16373            conflicts: current.status == CommitOutcomeStatus::Conflict,
16374            rejections: current.status == CommitOutcomeStatus::Rejected,
16375            outcomes: true,
16376            ..ChangeAccumulator::default()
16377        };
16378        if let Some(operations) = &current.operations {
16379            for operation in operations {
16380                batch.table(&operation.table);
16381            }
16382        }
16383        if let Err(error) = self
16384            .rebuild_overlay_if_dirty()
16385            .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
16386        {
16387            self.rollback_observation("syncular_outcome_resolution");
16388            self.failed_commits = previous_failed;
16389            self.conflicts = previous_conflicts;
16390            self.rejections = previous_rejections;
16391            return Err(error);
16392        }
16393        Ok(resolved)
16394    }
16395
16396    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
16397        self.schema_floor.as_ref()
16398    }
16399
16400    /// §7.3.5: the client's opaque auth-lease state, if any.
16401    pub fn lease_state(&self) -> Option<&LeaseState> {
16402        self.lease_state.as_ref()
16403    }
16404
16405    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
16406    /// the two lease codes set it; other errors leave leaseState untouched.
16407    fn record_lease_error(&mut self, code: &str) {
16408        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
16409            return;
16410        }
16411        let mut next = self.lease_state.clone().unwrap_or_default();
16412        next.error_code = Some(code.to_owned());
16413        self.set_lease_state(Some(next));
16414    }
16415
16416    fn set_lease_state(&mut self, next: Option<LeaseState>) {
16417        if self.lease_state == next {
16418            return;
16419        }
16420        if self.begin_observation("syncular_lease").is_err() {
16421            return;
16422        }
16423        self.lease_state = next;
16424        if let Some(lease) = &self.lease_state {
16425            if let Ok(json) = serde_json::to_string(lease) {
16426                self.set_meta(LEASE_STATE_KEY, &json);
16427            }
16428        } else {
16429            self.delete_meta(LEASE_STATE_KEY);
16430        }
16431        let batch = ChangeAccumulator {
16432            status: true,
16433            ..ChangeAccumulator::default()
16434        };
16435        if self.finish_observation("syncular_lease", batch).is_err() {
16436            self.rollback_observation("syncular_lease");
16437        }
16438    }
16439
16440    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
16441        if self.schema_floor == next {
16442            return;
16443        }
16444        if self.begin_observation("syncular_schema_floor").is_err() {
16445            return;
16446        }
16447        self.schema_floor = next;
16448        self.stopped = self.schema_floor.is_some();
16449        if let Some(floor) = &self.schema_floor {
16450            if let Ok(json) = serde_json::to_string(floor) {
16451                self.set_meta(SCHEMA_FLOOR_KEY, &json);
16452            }
16453        } else {
16454            self.delete_meta(SCHEMA_FLOOR_KEY);
16455        }
16456        let batch = ChangeAccumulator {
16457            status: true,
16458            ..ChangeAccumulator::default()
16459        };
16460        if self
16461            .finish_observation("syncular_schema_floor", batch)
16462            .is_err()
16463        {
16464            self.rollback_observation("syncular_schema_floor");
16465        }
16466    }
16467
16468    fn set_upgrading(&mut self, value: bool) {
16469        if self.upgrading == value {
16470            return;
16471        }
16472        if self.begin_observation("syncular_upgrading").is_err() {
16473            return;
16474        }
16475        self.upgrading = value;
16476        let batch = ChangeAccumulator {
16477            status: true,
16478            ..ChangeAccumulator::default()
16479        };
16480        if self
16481            .finish_observation("syncular_upgrading", batch)
16482            .is_err()
16483        {
16484            self.rollback_observation("syncular_upgrading");
16485        }
16486    }
16487
16488    pub fn sync_needed(&self) -> bool {
16489        self.sync_needed
16490    }
16491
16492    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
16493        let sub = self.subs.iter().find(|s| s.id == id)?;
16494        Some(SubscriptionStateView {
16495            id: sub.id.clone(),
16496            table: sub.table.clone(),
16497            status: sub.state.name().to_owned(),
16498            cursor: sub.cursor,
16499            has_resume_token: sub.bootstrap_state.is_some(),
16500            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
16501            reason_code: sub.reason_code.clone(),
16502        })
16503    }
16504
16505    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
16506        let schema_table = self
16507            .schema
16508            .table(table)
16509            .ok_or_else(|| format!("unknown table {table:?}"))?;
16510        let sql = format!(
16511            "SELECT * FROM {} ORDER BY {} ASC",
16512            visible_table(table),
16513            quote_ident(&schema_table.primary_key)
16514        );
16515        let mut stmt = self
16516            .conn
16517            .prepare(&sql)
16518            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16519        let mut rows = stmt
16520            .query([])
16521            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16522        let mut out = Vec::new();
16523        while let Some(row) = rows
16524            .next()
16525            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16526        {
16527            let mut values = Map::new();
16528            for (i, column) in schema_table.columns.iter().enumerate() {
16529                let value = row
16530                    .get_ref(i)
16531                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16532                values.insert(column.name.clone(), sql_ref_to_json(column, value));
16533            }
16534            let version: i64 = row
16535                .get(schema_table.columns.len())
16536                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16537            let row_id = match values.get(&schema_table.primary_key) {
16538                Some(Value::String(s)) => s.clone(),
16539                Some(Value::Number(n)) => n.to_string(),
16540                Some(Value::Bool(b)) => b.to_string(),
16541                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
16542            };
16543            out.push(RowState {
16544                row_id,
16545                version,
16546                values,
16547            });
16548        }
16549        Ok(out)
16550    }
16551
16552    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
16553    //
16554    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
16555    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
16556    // the op with `yrs`, re-encodes the whole doc state, and records a
16557    // baseVersion-less sparse upsert carrying only the primary key and the
16558    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
16559    // the overlay re-materializes the edit immediately (optimistic apply,
16560    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
16561    // Byte-compatible with `@syncular/crdt-yjs`.
16562
16563    /// The current stored value of a `crdt` column for one row — the visible
16564    /// (optimistic) bytes, or `None` when the row is absent or the column is
16565    /// NULL (the empty document, §5.10.1). Errors if the column is not a
16566    /// `crdt` column (guards the app against a typo'd column name).
16567    #[cfg(feature = "crdt-yjs")]
16568    fn crdt_column_bytes(
16569        &self,
16570        table: &str,
16571        row_id: &str,
16572        column: &str,
16573    ) -> Result<Option<Vec<u8>>, String> {
16574        let schema_table = self
16575            .schema
16576            .table(table)
16577            .ok_or_else(|| format!("unknown table {table:?}"))?;
16578        let col = schema_table
16579            .columns
16580            .iter()
16581            .find(|c| c.name == column)
16582            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
16583        if col.ty != ColumnType::Crdt {
16584            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
16585        }
16586        let sql = format!(
16587            "SELECT {} FROM {} WHERE {}",
16588            quote_ident(column),
16589            visible_table(table),
16590            row_id_predicate(schema_table)
16591        );
16592        let bytes: Option<Vec<u8>> = self
16593            .conn
16594            .prepare_cached(&sql)
16595            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16596            .query_row(rusqlite::params![row_id], |row| {
16597                row.get::<_, Option<Vec<u8>>>(0)
16598            })
16599            .map_err(|e| match e {
16600                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
16601                other => Self::sqlite_failure(&self.storage_failure, other),
16602            })?;
16603        Ok(bytes)
16604    }
16605
16606    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
16607    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
16608    /// An absent row / NULL column is the empty document (empty string).
16609    #[cfg(feature = "crdt-yjs")]
16610    pub fn crdt_text(
16611        &self,
16612        table: &str,
16613        row_id: &str,
16614        column: &str,
16615        name: &str,
16616    ) -> Result<String, String> {
16617        let bytes = self
16618            .crdt_column_bytes(table, row_id, column)?
16619            .unwrap_or_default();
16620        crate::crdt::text(&bytes, name)
16621    }
16622
16623    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
16624    /// record the resulting crdt-only sparse operation. Returns the enqueued
16625    /// `clientCommitId`.
16626    #[cfg(feature = "crdt-yjs")]
16627    pub fn crdt_insert_text(
16628        &mut self,
16629        table: &str,
16630        row_id: &str,
16631        column: &str,
16632        name: &str,
16633        index: u32,
16634        value: &str,
16635    ) -> Result<String, ClientError> {
16636        let prior = self.storage_failure.borrow_mut().take();
16637        let outcome = (|| -> Result<String, String> {
16638            let current = self
16639                .crdt_column_bytes(table, row_id, column)?
16640                .unwrap_or_default();
16641            let update = crate::crdt::insert_text(&current, name, index, value)?;
16642            self.perform_crdt_push_update(table, row_id, column, &update)
16643        })();
16644        self.finish_authoring(prior, outcome)
16645    }
16646
16647    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
16648    /// record the resulting crdt-only sparse operation. Returns the enqueued
16649    /// `clientCommitId`.
16650    #[cfg(feature = "crdt-yjs")]
16651    pub fn crdt_delete_text(
16652        &mut self,
16653        table: &str,
16654        row_id: &str,
16655        column: &str,
16656        name: &str,
16657        index: u32,
16658        len: u32,
16659    ) -> Result<String, ClientError> {
16660        let prior = self.storage_failure.borrow_mut().take();
16661        let outcome = (|| -> Result<String, String> {
16662            let current = self
16663                .crdt_column_bytes(table, row_id, column)?
16664                .unwrap_or_default();
16665            let update = crate::crdt::delete_text(&current, name, index, len)?;
16666            self.perform_crdt_push_update(table, row_id, column, &update)
16667        })();
16668        self.finish_authoring(prior, outcome)
16669    }
16670
16671    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
16672    /// `crdt` column's current state and record the resulting crdt-only sparse
16673    /// operation. The app authored the update with its own `yrs` model.
16674    /// Returns the enqueued `clientCommitId`.
16675    #[cfg(feature = "crdt-yjs")]
16676    pub fn crdt_apply_update(
16677        &mut self,
16678        table: &str,
16679        row_id: &str,
16680        column: &str,
16681        update: &[u8],
16682    ) -> Result<String, ClientError> {
16683        let prior = self.storage_failure.borrow_mut().take();
16684        let outcome = (|| -> Result<String, String> {
16685            let current = self
16686                .crdt_column_bytes(table, row_id, column)?
16687                .unwrap_or_default();
16688            let next = crate::crdt::apply_update(&current, update)?;
16689            self.perform_crdt_push_update(table, row_id, column, &next)
16690        })();
16691        self.finish_authoring(prior, outcome)
16692    }
16693
16694    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
16695    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
16696    /// enqueue it. The server merges the crdt column and leaves every other
16697    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
16698    /// clobber a concurrent edit to another column. A locally absent row
16699    /// records the same partial operation; the server answers per §6.2.
16700    #[cfg(feature = "crdt-yjs")]
16701    fn perform_crdt_push_update(
16702        &mut self,
16703        table: &str,
16704        row_id: &str,
16705        column: &str,
16706        crdt_bytes: &[u8],
16707    ) -> Result<String, String> {
16708        let mut bytes_obj = Map::new();
16709        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
16710        let mut partial = Map::new();
16711        partial.insert(column.to_owned(), Value::Object(bytes_obj));
16712        self.perform_patch(table, row_id, partial, None)
16713    }
16714
16715    /// Run an arbitrary read-only SQL query against the local database and
16716    /// return each row as a `column-name → JSON value` map. This is the seam
16717    /// the React `useSyncQuery` live-query API needs (it takes app-authored
16718    /// SQL over the visible tables/views, not a fixed table read like
16719    /// [`read_rows`]).
16720    ///
16721    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
16722    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
16723    /// same envelope the command surface uses everywhere else. Output BLOB
16724    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
16725    ///
16726    /// The result column typing is dynamic (SQLite's stored affinity), because
16727    /// arbitrary SQL can alias, join, and compute — there is no schema column
16728    /// to consult per output cell, unlike [`read_rows`].
16729    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
16730        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
16731    }
16732
16733    /// Repository benchmark and conformance access, present only with `bench-internals`.
16734    #[cfg(feature = "bench-internals")]
16735    #[doc(hidden)]
16736    pub fn benchmark_connection(&mut self) -> &mut Connection {
16737        &mut self.conn
16738    }
16739
16740    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
16741    #[cfg(feature = "bench-internals")]
16742    #[doc(hidden)]
16743    pub fn benchmark_phases(
16744        &self,
16745        enabled: Option<bool>,
16746        reset: bool,
16747    ) -> Result<Option<Value>, String> {
16748        self.benchmark_phases.configure(enabled, reset)?;
16749        Ok(self.benchmark_phases.snapshot())
16750    }
16751
16752    /// One serialized, read-only authority observation, including durable coverage.
16753    pub fn authority_snapshot(&self) -> Result<Value, String> {
16754        validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
16755        if self.limits.authority_reads.is_empty() {
16756            return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
16757        }
16758        self.conn
16759            .execute_batch("SAVEPOINT authority_snapshot")
16760            .map_err(|e| e.to_string())?;
16761        let result = (|| {
16762            let revision = self
16763                .get_meta(LOCAL_REVISION_KEY)
16764                .ok_or("sync.local_corrupt: authority revision is missing")?;
16765            let parsed_revision = revision
16766                .parse::<u64>()
16767                .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
16768            if parsed_revision.to_string() != revision {
16769                return Err("sync.local_corrupt: authority revision is invalid".into());
16770            }
16771            let mut tables = Vec::new();
16772            for read in &self.limits.authority_reads {
16773                let table = self
16774                    .schema
16775                    .table(&read.table)
16776                    .ok_or("client.authority_read_forbidden: authority table is not declared")?;
16777                let scalar = |column: &str, value: &Value| -> Result<String, String> {
16778                    let ty = table
16779                        .columns
16780                        .iter()
16781                        .find(|c| c.name == column)
16782                        .expect("validated authority column")
16783                        .ty;
16784                    if ty == ColumnType::Boolean {
16785                        if let Some(value) = value.as_bool() {
16786                            return Ok(value.to_string());
16787                        }
16788                        return match value.as_i64() {
16789                            Some(0) => Ok("false".into()),
16790                            Some(1) => Ok("true".into()),
16791                            _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
16792                        };
16793                    }
16794                    match value {
16795                        Value::String(s) => Ok(s.clone()),
16796                        Value::Number(n) => Ok(n.to_string()),
16797                        Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
16798                            .get("$bigint")
16799                            .and_then(Value::as_str)
16800                            .map(str::to_owned)
16801                            .ok_or("sync.local_corrupt: authority integer is invalid".into()),
16802                        _ => {
16803                            Err("sync.local_corrupt: authority scalar is missing or invalid".into())
16804                        }
16805                    }
16806                };
16807                let matches = |row: &QueryRow| -> Result<bool, String> {
16808                    for (variable, values) in &read.scopes {
16809                        let column = table
16810                            .scope_column(variable)
16811                            .expect("validated authority scope");
16812                        let value = row
16813                            .get(column)
16814                            .ok_or("sync.local_corrupt: authority scope column is missing")?;
16815                        if value.is_null() || !values.contains(&scalar(column, value)?) {
16816                            return Ok(false);
16817                        }
16818                    }
16819                    Ok(true)
16820                };
16821                let selected = read
16822                    .columns
16823                    .iter()
16824                    .map(|c| quote_ident(c))
16825                    .collect::<Vec<_>>()
16826                    .join(",");
16827                let mut intent = BTreeSet::new();
16828                let mut relevant = BTreeSet::new();
16829                for row in query_connection(&self.conn,
16830                    "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=?",
16831                    &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
16832                    let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
16833                    intent.insert(id.clone());
16834                    if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
16835                        let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
16836                        if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
16837                    }
16838                }
16839                let mut rows = BTreeMap::new();
16840                for full in [base_table(&read.table), visible_table(&read.table)] {
16841                    for mut values in query_connection(
16842                        &self.conn,
16843                        &format!(
16844                            "SELECT {selected},_syncular_version AS authority_version FROM {full}"
16845                        ),
16846                        &[],
16847                    )
16848                    .map_err(|e| e.to_string())?
16849                    {
16850                        if !matches(&values)? {
16851                            continue;
16852                        }
16853                        let id = scalar(
16854                            &table.primary_key,
16855                            values
16856                                .get(&table.primary_key)
16857                                .expect("selected primary key"),
16858                        )?;
16859                        relevant.insert(id.clone());
16860                        if full == base_table(&read.table) {
16861                            let version = values
16862                                .remove("authority_version")
16863                                .expect("selected version");
16864                            let version = version
16865                                .as_i64()
16866                                .ok_or("sync.local_corrupt: authority server version is invalid")?;
16867                            if version >= 0 {
16868                                rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
16869                            }
16870                        }
16871                    }
16872                }
16873                intent.retain(|id| relevant.contains(id));
16874                let rows = rows.into_values().collect::<Vec<_>>();
16875                let mut persisted = Vec::new();
16876                for row in query_connection(
16877                    &self.conn,
16878                    "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
16879                    &[Value::String(read.table.clone())],
16880                )
16881                .map_err(|e| e.message)?
16882                {
16883                    let state: Value = serde_json::from_str(
16884                        row.get("state_json")
16885                            .and_then(Value::as_str)
16886                            .ok_or("sync.local_corrupt: authority subscription state is missing")?,
16887                    )
16888                    .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
16889                    let effective = state.get("effectiveScopes").and_then(Value::as_object);
16890                    let status = state
16891                        .get("status")
16892                        .and_then(Value::as_str)
16893                        .ok_or("sync.local_corrupt: authority subscription status is missing")?;
16894                    let cursor = state
16895                        .get("cursor")
16896                        .and_then(Value::as_i64)
16897                        .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
16898                    let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
16899                        && status == "active"
16900                        && cursor >= 0
16901                        && state.get("bootstrapState").is_none_or(Value::is_null)
16902                        && state.get("params").is_none_or(Value::is_null);
16903                    let exposed = effective.map(|s| {
16904                        read.scopes
16905                            .keys()
16906                            .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
16907                            .collect::<Map<_, _>>()
16908                    });
16909                    let requested = read
16910                        .scopes
16911                        .keys()
16912                        .map(|v| {
16913                            (
16914                                v.clone(),
16915                                state
16916                                    .get("requested")
16917                                    .and_then(|s| s.get(v))
16918                                    .cloned()
16919                                    .unwrap_or(json!([])),
16920                            )
16921                        })
16922                        .collect::<Map<_, _>>();
16923                    persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
16924                }
16925                fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
16926                    match selectors.split_first() {
16927                        None => !candidates.is_empty(),
16928                        Some(((variable, values), rest)) => values.iter().all(|value| {
16929                            covered(
16930                                rest,
16931                                &candidates
16932                                    .iter()
16933                                    .copied()
16934                                    .filter(|p| {
16935                                        p["effectiveScopes"][variable.as_str()]
16936                                            .as_array()
16937                                            .is_some_and(|held| {
16938                                                held.contains(&Value::String(value.clone()))
16939                                                    || held.contains(&Value::String("*".into()))
16940                                            })
16941                                    })
16942                                    .collect::<Vec<_>>(),
16943                            )
16944                        }),
16945                    }
16946                }
16947                let coverage = if covered(
16948                    &read.scopes.iter().collect::<Vec<_>>(),
16949                    &persisted
16950                        .iter()
16951                        .filter(|p| p["complete"] == true)
16952                        .collect::<Vec<_>>(),
16953                ) {
16954                    "complete"
16955                } else if persisted.iter().any(|p| {
16956                    p["status"] == "active"
16957                        && read.scopes.iter().all(|(v, values)| {
16958                            values.iter().any(|value| {
16959                                p["requestedScopes"][v].as_array().is_some_and(|held| {
16960                                    held.contains(&Value::String(value.clone()))
16961                                        || held.contains(&Value::String("*".into()))
16962                                })
16963                            })
16964                        })
16965                }) {
16966                    "pending"
16967                } else {
16968                    "missing"
16969                };
16970                tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
16971            }
16972            Ok(
16973                json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
16974            )
16975        })();
16976        match result {
16977            Ok(snapshot) => {
16978                self.conn
16979                    .execute_batch("RELEASE authority_snapshot")
16980                    .map_err(|e| e.to_string())?;
16981                Ok(snapshot)
16982            }
16983            Err(error) => {
16984                self.conn
16985                    .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
16986                    .map_err(|e| format!("{error}; rollback failed: {e}"))?;
16987                Err(error)
16988            }
16989        }
16990    }
16991
16992    /// Rows, coverage, and local revision from one SQLite read snapshot.
16993    /// §7.5 atomic snapshot read. An `owner` records a failure in
16994    /// `diagnostics_snapshot().query_failures` until its next successful read.
16995    pub fn query_snapshot(
16996        &mut self,
16997        sql: &str,
16998        params: &[QueryValue],
16999        coverage: &[WindowCoverage],
17000        owner: Option<QueryOwner<'_>>,
17001    ) -> Result<QuerySnapshot, String> {
17002        let result = snapshot_connection(&self.conn, sql, params, coverage);
17003        if let Some(owner) = owner {
17004            self.record_query_read(owner, result.as_ref().err());
17005        }
17006        result.map_err(|failure| failure.to_string())
17007    }
17008
17009    /// §7.6: record the outcome of one owned snapshot read, including reads a
17010    /// host ran on a separate connection. A success removes the owner's entry;
17011    /// an identical failure leaves it unchanged; a changed failure moves last.
17012    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
17013        let position = self
17014            .query_failures
17015            .iter()
17016            .position(|entry| entry.id == owner.id);
17017        let Some(failure) = failure else {
17018            if let Some(index) = position {
17019                self.query_failures.remove(index);
17020            }
17021            return;
17022        };
17023        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
17024            .into_iter()
17025            .map(str::to_owned)
17026            .collect();
17027        let code = failure.code.unwrap_or("client.query_failed").to_owned();
17028        if position.is_some_and(|index| {
17029            let entry = &self.query_failures[index];
17030            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
17031        }) {
17032            return;
17033        }
17034        if let Some(index) = position {
17035            self.query_failures.remove(index);
17036        }
17037        self.query_failures.push(DiagnosticQueryFailure {
17038            id: owner.id.to_owned(),
17039            tables,
17040            code,
17041            sqlite_code: failure.sqlite_code,
17042            at_ms: self.clock_now_ms(),
17043        });
17044        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
17045            self.query_failures.remove(0);
17046        }
17047    }
17048
17049    // -- request building ---------------------------------------------------------
17050
17051    fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
17052        #[cfg(feature = "bench-internals")]
17053        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
17054        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
17055        let max_commits = self.limits.max_push_commits_per_request;
17056        let max_ops = self
17057            .limits
17058            .max_push_operations_per_request
17059            .unwrap_or(PUSH_OPS_PER_REQUEST);
17060        let byte_cap = self.limits.max_push_request_bytes;
17061        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
17062        // bare URL — capability negotiation, decided per transport.
17063        let accept = self.limits.accept.unwrap_or(if url_capable {
17064            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
17065        } else {
17066            DEFAULT_ACCEPT
17067        });
17068        let header = Frame::ReqHeader {
17069            client_id: self.client_id.clone(),
17070            schema_version: self.schema.version,
17071            log_epoch: log_epoch.clone(),
17072        };
17073        // §1.5: the lead frames are REQ_HEADER plus the optional ROUND_FAILURE,
17074        // which stays ahead of every push, pull and subscription frame; the
17075        // byte-capacity probes below measure this exact sequence.
17076        let mut lead = vec![header];
17077        lead.extend(
17078            self.round_failure
17079                .iter()
17080                .map(|code| Frame::RoundFailure { code: code.clone() }),
17081        );
17082        let pull = Frame::PullHeader {
17083            limit_commits: self.limits.limit_commits.unwrap_or(0),
17084            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
17085            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
17086            accept,
17087        };
17088        let mut fresh = Vec::new();
17089        let mut sub_frames = Vec::new();
17090        for sub in &self.subs {
17091            if sub.state != SubState::Active {
17092                continue;
17093            }
17094            let mut scopes = sub.requested.clone();
17095            sort_scope_map(&mut scopes);
17096            sub_frames.push(Frame::Subscription {
17097                id: sub.id.clone(),
17098                table: sub.table.clone(),
17099                scopes,
17100                params: sub.params.clone().map(RawJson),
17101                cursor: sub.cursor,
17102                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
17103            });
17104            fresh.push((
17105                sub.id.clone(),
17106                sub.cursor < 0 && sub.bootstrap_state.is_none(),
17107            ));
17108        }
17109        // The fixed frames (order-independent size) and the header+pull probe
17110        // both encode canonically, so the byte budget is the exact wire size.
17111        let fixed_bytes = match byte_cap {
17112            Some(_) => {
17113                let mut frames = lead.clone();
17114                frames.push(pull.clone());
17115                frames.extend(sub_frames.iter().cloned());
17116                encode_message(&Message {
17117                    wire_version: WIRE_VERSION,
17118                    msg_kind: MsgKind::Request,
17119                    frames,
17120                })
17121                .len()
17122            }
17123            None => 0,
17124        };
17125        if let Some(cap) = byte_cap {
17126            if fixed_bytes > cap {
17127                return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
17128            }
17129        }
17130        let base_bytes = match byte_cap {
17131            Some(_) => {
17132                let mut frames = lead.clone();
17133                frames.push(pull.clone());
17134                encode_message(&Message {
17135                    wire_version: WIRE_VERSION,
17136                    msg_kind: MsgKind::Request,
17137                    frames,
17138                })
17139                .len()
17140            }
17141            None => 0,
17142        };
17143        let total_outbox = if log_epoch.is_some() {
17144            self.outbox.len()
17145        } else {
17146            0
17147        };
17148        let mut push_frames: Vec<Frame> = Vec::new();
17149        let mut pushed_ids = Vec::new();
17150        let mut ops_in_request = 0usize;
17151        let mut bytes_in_request = 0usize;
17152        let mut deferred_commits = 0usize;
17153        #[cfg(feature = "bench-internals")]
17154        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
17155        for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
17156            // §6.1/§7.1: the commit-count and operation caps are hard. A
17157            // first commit over the operation cap is a typed capacity error
17158            // and stays queued; a later one defers with the complete suffix.
17159            if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
17160                deferred_commits = total_outbox - index;
17161                break;
17162            }
17163            if ops_in_request + commit.ops.len() > max_ops {
17164                if push_frames.is_empty() {
17165                    return Err(Self::push_capacity_outcome(
17166                        "operations",
17167                        max_ops,
17168                        commit.ops.len(),
17169                        Some(commit.client_commit_id.as_str()),
17170                    ));
17171                }
17172                deferred_commits = total_outbox - index;
17173                break;
17174            }
17175            let operations = commit
17176                .ops
17177                .iter()
17178                .map(|op| {
17179                    let payload = op.values.as_ref().and_then(|values| {
17180                        let table = self.schema.table(&op.table)?;
17181                        // §0: outbox entries encode at send time with the
17182                        // current codec (validated at mutate()). §6.1: the
17183                        // values map's keys are the sparse presence set.
17184                        // §5.11: encrypted columns are encrypted here, with
17185                        // an absent key-id selector read from the stored row.
17186                        // `sync_inner` runs `drop_unencodable_outbox` first,
17187                        // so an encrypt failure cannot reach this encode; the
17188                        // author seam validated every other failure.
17189                        let fallback = self.stored_key_fallback(table, &op.row_id);
17190                        encode_sparse_row_json(
17191                            table,
17192                            &op.row_id,
17193                            values,
17194                            &self.encryption,
17195                            fallback.as_ref(),
17196                        )
17197                        .ok()
17198                    });
17199                    ssp2::model::Operation {
17200                        table: op.table.clone(),
17201                        row_id: op.row_id.clone(),
17202                        op: if op.upsert { Op::Upsert } else { Op::Delete },
17203                        base_version: op.base_version,
17204                        payload,
17205                    }
17206                })
17207                .collect();
17208            let frame = Frame::PushCommit {
17209                client_commit_id: commit.client_commit_id.clone(),
17210                operations,
17211            };
17212            let frame_bytes = match byte_cap {
17213                Some(_) => {
17214                    let mut frames = lead.clone();
17215                    frames.push(frame.clone());
17216                    frames.push(pull.clone());
17217                    encode_message(&Message {
17218                        wire_version: WIRE_VERSION,
17219                        msg_kind: MsgKind::Request,
17220                        frames,
17221                    })
17222                    .len()
17223                        - base_bytes
17224                }
17225                None => 0,
17226            };
17227            // The head is always encoded so the caller can report the full
17228            // projected request size; a later commit defers once it no longer
17229            // fits.
17230            if let Some(cap) = byte_cap {
17231                if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
17232                    deferred_commits = total_outbox - index;
17233                    break;
17234                }
17235            }
17236            push_frames.push(frame);
17237            pushed_ids.push(commit.client_commit_id.clone());
17238            ops_in_request += commit.ops.len();
17239            bytes_in_request += frame_bytes;
17240        }
17241        #[cfg(feature = "bench-internals")]
17242        drop(outbox_phase);
17243        if let Some(cap) = byte_cap {
17244            if fixed_bytes + bytes_in_request > cap {
17245                // The head alone does not fit this budget; the commit stays
17246                // queued and no transport round is attempted.
17247                let blocked = if push_frames.is_empty() {
17248                    None
17249                } else {
17250                    self.outbox.first().map(|c| c.client_commit_id.as_str())
17251                };
17252                return Err(Self::push_capacity_outcome(
17253                    "bytes",
17254                    cap,
17255                    fixed_bytes + bytes_in_request,
17256                    blocked,
17257                ));
17258            }
17259        }
17260        let mut frames = lead;
17261        frames.extend(push_frames);
17262        frames.push(pull);
17263        frames.extend(sub_frames);
17264        let message = Message {
17265            wire_version: WIRE_VERSION,
17266            msg_kind: MsgKind::Request,
17267            frames,
17268        };
17269        Ok((
17270            message,
17271            RequestMeta {
17272                pushed_ids,
17273                fresh,
17274                accept,
17275                deferred_commits,
17276            },
17277        ))
17278    }
17279
17280    /// §7.1: a configured push capacity limit cannot carry this request.
17281    fn push_capacity_outcome(
17282        kind: &str,
17283        limit: usize,
17284        size: usize,
17285        client_commit_id: Option<&str>,
17286    ) -> Box<SyncOutcome> {
17287        let mut details = Map::new();
17288        details.insert("kind".to_owned(), Value::from(kind));
17289        details.insert("limit".to_owned(), Value::from(limit as u64));
17290        details.insert("size".to_owned(), Value::from(size as u64));
17291        if let Some(id) = client_commit_id {
17292            details.insert("clientCommitId".to_owned(), Value::from(id));
17293        }
17294        Box::new(SyncOutcome::Failed {
17295            error_code: "client.push_request_too_large".into(),
17296            message: "the push request exceeds a configured capacity limit".into(),
17297            details: Some(Value::Object(details)),
17298            recommended_action: None,
17299        })
17300    }
17301
17302    // -- sync -------------------------------------------------------------------
17303
17304    /// Cloneable observation handle; no client lock is needed to read it.
17305    pub fn progress(&self) -> ProgressObserver {
17306        self.progress.clone()
17307    }
17308
17309    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
17310        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
17311            Ok(prepared) => prepared,
17312            Err(outcome) => return *outcome,
17313        };
17314        // The round's network deadline rides `PreparedSyncRound` and is scoped
17315        // to each exchange, so uploads, continuations, the main request, and
17316        // every segment fetch share one monotonic anchor without leaving
17317        // deadline state on a reused transport.
17318        let mut next = prepared;
17319        loop {
17320            match self.apply_sync_round(next.exchange(transport)) {
17321                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
17322                crate::AppliedSyncRound::Complete {
17323                    outcome, controls, ..
17324                } => {
17325                    if self.transport_enabled {
17326                        for text in controls {
17327                            let _ = transport.realtime_send(&text);
17328                        }
17329                    } else {
17330                        self.disconnect_realtime(transport);
17331                    }
17332                    return outcome;
17333                }
17334            }
17335        }
17336    }
17337
17338    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
17339        if let SyncOutcome::Failed {
17340            error_code,
17341            message,
17342            details,
17343            ..
17344        } = &mut outcome
17345        {
17346            if let Some(failure) = self.storage_failure.borrow().as_ref() {
17347                let code = failure
17348                    .code
17349                    .expect("retained storage failures are classified");
17350                *error_code = code.to_owned();
17351                *message = match code {
17352                    "client.storage_busy" => "local SQLite storage is busy",
17353                    "client.storage_full" => "local SQLite storage is full",
17354                    "client.storage_io" => "local SQLite storage I/O failed",
17355                    "client.storage_corrupt" => "local SQLite storage is corrupt",
17356                    _ => unreachable!("unrecognized classified storage failure"),
17357                }
17358                .to_owned();
17359                *details = failure.details();
17360            }
17361        }
17362        let retry_delay_ms = self.round_retry_delay_ms;
17363        self.progress.update(|p| match &outcome {
17364            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
17365                if report.failed.is_empty() =>
17366            {
17367                p.state = ProgressState::Complete
17368            }
17369            SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
17370                p.state = ProgressState::Failed;
17371                p.error_code = Some("sync.scope_revoked".into());
17372            }
17373            SyncOutcome::Failed { error_code, .. } => {
17374                p.state = ProgressState::Failed;
17375                p.error_code = Some(Self::diagnostic_code(error_code));
17376                p.retry_delay_ms = retry_delay_ms;
17377            }
17378            SyncOutcome::RealtimeUnavailable { .. } => {
17379                p.state = ProgressState::Failed;
17380                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
17381                p.retry_delay_ms = retry_delay_ms;
17382            }
17383        });
17384        let completed_at_ms = self.clock_now_ms();
17385        self.last_round = Some(match &outcome {
17386            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
17387                status: "succeeded".to_owned(),
17388                started_at_ms,
17389                completed_at_ms,
17390                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17391                counters: Some(DiagnosticRoundCounters {
17392                    pushed: report.pushed,
17393                    applied: report.applied.len(),
17394                    rejected: report.rejected.len(),
17395                    retryable: report.retryable.len(),
17396                    conflicts: report.conflicts,
17397                    commits_applied: report.commits_applied,
17398                    segment_rows_applied: report.segment_rows_applied,
17399                    bootstrapping: report.bootstrapping.len(),
17400                    resets: report.resets.len(),
17401                    revoked: report.revoked.len(),
17402                    failed: report.failed.len(),
17403                    deferred_commits: report.deferred_commits,
17404                }),
17405                error_code: None,
17406            },
17407            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
17408                status: "failed".to_owned(),
17409                started_at_ms,
17410                completed_at_ms,
17411                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17412                counters: None,
17413                error_code: Some(Self::diagnostic_code(error_code)),
17414            },
17415            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
17416                status: "failed".to_owned(),
17417                started_at_ms,
17418                completed_at_ms,
17419                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17420                counters: None,
17421                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
17422            },
17423        });
17424        outcome
17425    }
17426
17427    /// Capture one request on the client owner. Mutations made after this
17428    /// call remain in the outbox for a later round; no network I/O occurs here.
17429    pub fn prepare_sync_round(
17430        &mut self,
17431        url_capable: bool,
17432    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
17433        if !self.transport_enabled {
17434            return Err(Box::new(SyncOutcome::Failed {
17435                error_code: "sync.offline".into(),
17436                message: "transport gate is closed".into(),
17437                details: None,
17438                recommended_action: None,
17439            }));
17440        }
17441        if self.active_round.is_some() {
17442            return Err(Box::new(SyncOutcome::Failed {
17443                error_code: "client.round_in_progress".into(),
17444                message: "a sync round is already in flight".into(),
17445                details: None,
17446                recommended_action: None,
17447            }));
17448        }
17449        self.storage_failure.borrow_mut().take();
17450        self.progress.start();
17451        self.round_retry_delay_ms = None;
17452        let started_at_ms = self.clock_now_ms();
17453        let prepared = (|| {
17454            if self.stopped {
17455                return Err(Box::new(SyncOutcome::Ok(SyncReport {
17456                    schema_floor: self.schema_floor.clone(),
17457                    ..SyncReport::default()
17458                })));
17459            }
17460            if self.security_preflight {
17461                return Err(Box::new(SyncOutcome::Failed {
17462                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
17463                    message: "security preflight must complete before syncing".into(),
17464                    details: None,
17465                    recommended_action: None,
17466                }));
17467            }
17468            self.set_sync_needed(false, false);
17469            self.drain_pending_evictions()
17470                .and_then(|_| self.drop_incompatible_outbox())
17471                .and_then(|_| self.drop_unencodable_outbox(true, None))
17472                .map_err(|message| {
17473                    Box::new(SyncOutcome::Failed {
17474                        error_code: "storage.failed".into(),
17475                        message,
17476                        details: None,
17477                        recommended_action: None,
17478                    })
17479                })?;
17480            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
17481                self.pending_blob_uploads().map_err(|e| {
17482                    Box::new(SyncOutcome::Failed {
17483                        error_code: e.code,
17484                        message: e.message,
17485                        details: e.details,
17486                        recommended_action: None,
17487                    })
17488                })?
17489            } else {
17490                Vec::new()
17491            };
17492            if self.realtime_state != RealtimeState::Connected
17493                && self.realtime_policy == RealtimePolicy::Required
17494            {
17495                let retry_delay_ms = self.schedule_background_retry();
17496                self.realtime_retry_delay_ms = Some(retry_delay_ms);
17497                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
17498                    state: self.realtime_state,
17499                    reason_code: self.realtime_reason_code.clone(),
17500                    retry_delay_ms,
17501                }));
17502            }
17503            let (message, meta) = self.build_request(url_capable)?;
17504            let id = uuid::Uuid::new_v4();
17505            self.active_round = Some(id);
17506            let phase = if uploads.is_empty() {
17507                crate::round::RoundPhase::Sync
17508            } else {
17509                crate::round::RoundPhase::UploadGrant
17510            };
17511            Ok(crate::PreparedSyncRound {
17512                id,
17513                started_at_ms,
17514                message,
17515                meta,
17516                uploads: uploads.into(),
17517                phase,
17518                encoded: None,
17519                continuation: None,
17520                completed: None,
17521                realtime: self.realtime_state == RealtimeState::Connected,
17522                scopes: self
17523                    .subs
17524                    .iter()
17525                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
17526                    .collect(),
17527                #[cfg(feature = "bench-internals")]
17528                benchmark_phases: self.benchmark_phases.clone(),
17529                progress: self.progress.clone(),
17530                fixed_now: self.now_ms,
17531                round_deadline_at: None,
17532            })
17533        })();
17534        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
17535    }
17536
17537    /// Discard the reply of an old authorization/subscription context. Its
17538    /// submitted commits stay durable and can be retried with the same ids.
17539    pub fn cancel_sync_round(&mut self) {
17540        self.active_round = None;
17541    }
17542
17543    /// Apply captured network results on the owner, preserving all normal
17544    /// version, schema, content-address and cursor checks. No network I/O.
17545    pub fn apply_sync_round(
17546        &mut self,
17547        completed: crate::CompletedSyncRound,
17548    ) -> crate::AppliedSyncRound {
17549        let prepared = completed.prepared;
17550        if self.active_round != Some(prepared.id) || self.security_preflight {
17551            return crate::AppliedSyncRound::Complete {
17552                outcome: SyncOutcome::Failed {
17553                    error_code: "client.round_cancelled".into(),
17554                    message: "sync round belongs to a released client context".into(),
17555                    details: None,
17556                    recommended_action: None,
17557                },
17558                controls: Vec::new(),
17559                more: false,
17560                bootstrap_advanced: false,
17561            };
17562        }
17563        self.storage_failure.borrow_mut().take();
17564        let (response, mut downloads, transport_failed) = match completed.exchange {
17565            crate::round::ExchangeResult::Upload { id, result } => {
17566                let result = result.and_then(|()| {
17567                    self.conn
17568                        .execute(
17569                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
17570                            rusqlite::params![id],
17571                        )
17572                        .map(|_| ())
17573                        .map_err(|error| {
17574                            TransportError::new(
17575                                "client.failed",
17576                                Self::sqlite_failure(&self.storage_failure, error),
17577                            )
17578                        })
17579                });
17580                match result {
17581                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
17582                    Err(TransportError {
17583                        code,
17584                        message,
17585                        details,
17586                    }) => {
17587                        self.active_round = None;
17588                        if Self::retryable_failure_code(&code)
17589                            && !Self::unreachable_transport(&details)
17590                        {
17591                            self.schedule_background_retry();
17592                        }
17593                        let recommended_action = recommended_action_of(&details);
17594                        let outcome = self.finish_sync_round(
17595                            prepared.started_at_ms,
17596                            SyncOutcome::Failed {
17597                                error_code: code,
17598                                message,
17599                                details,
17600                                recommended_action,
17601                            },
17602                        );
17603                        return crate::AppliedSyncRound::Complete {
17604                            outcome,
17605                            controls: Vec::new(),
17606                            more: false,
17607                            bootstrap_advanced: false,
17608                        };
17609                    }
17610                }
17611            }
17612            crate::round::ExchangeResult::Reply {
17613                response,
17614                downloads,
17615                transport_failed,
17616            } => (response, downloads, transport_failed),
17617        };
17618        self.active_round = None;
17619        let before = self
17620            .subs
17621            .iter()
17622            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17623            .collect::<Vec<_>>();
17624        let outcome = (|| {
17625            let response = match response {
17626                Ok(response) => {
17627                    // §1.5: a complete, decodable response ends the report, an
17628                    // in-band ERROR included (the server already recorded it).
17629                    self.round_failure = None;
17630                    response
17631                }
17632                Err(TransportError {
17633                    code,
17634                    message,
17635                    details,
17636                }) => {
17637                    // §1.5: the server cannot see a round whose transport
17638                    // failed or whose response never decoded, so its code rides
17639                    // the following requests. A code the frame grammar rejects
17640                    // is not reportable, and the previous code stays.
17641                    if ssp2::model::valid_round_failure_code(&code) {
17642                        self.round_failure = Some(code.clone());
17643                    }
17644                    if transport_failed {
17645                        if prepared.realtime {
17646                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
17647                        }
17648                        self.record_lease_error(&code);
17649                    }
17650                    if Self::retryable_failure_code(&code) && !Self::unreachable_transport(&details)
17651                    {
17652                        let delay = self.schedule_background_retry();
17653                        if prepared.realtime && transport_failed {
17654                            self.realtime_retry_delay_ms = Some(delay);
17655                        }
17656                    }
17657                    let recommended_action = recommended_action_of(&details);
17658                    return SyncOutcome::Failed {
17659                        error_code: code,
17660                        message,
17661                        details,
17662                        recommended_action,
17663                    };
17664                }
17665            };
17666            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
17667            if let SyncOutcome::Ok(report) = &mut outcome {
17668                report.deferred_commits = prepared.meta.deferred_commits;
17669            }
17670            match &outcome {
17671                SyncOutcome::Ok(_) => self.reset_background_retry(),
17672                SyncOutcome::Failed {
17673                    error_code,
17674                    details,
17675                    ..
17676                } if Self::retryable_failure_code(error_code)
17677                    && !Self::unreachable_transport(details) =>
17678                {
17679                    self.schedule_background_retry();
17680                }
17681                _ => {}
17682            }
17683            if prepared.meta.deferred_commits > 0 {
17684                self.set_sync_needed(true, true);
17685            }
17686            self.previous_version_lifetime_check();
17687            outcome
17688        })();
17689        let more = self.transport_enabled
17690            && matches!(&outcome, SyncOutcome::Ok(report)
17691                if report.schema_floor.is_none()
17692                    && (!report.bootstrapping.is_empty()
17693                        || report.commits_applied > 0
17694                        || report.segment_rows_applied > 0
17695                        || !report.resets.is_empty()
17696                        || report.deferred_commits > 0
17697                        || self.sync_needed));
17698        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
17699            && before
17700                != self
17701                    .subs
17702                    .iter()
17703                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17704                    .collect::<Vec<_>>();
17705        crate::AppliedSyncRound::Complete {
17706            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
17707            controls: if self.transport_enabled {
17708                downloads.controls
17709            } else {
17710                Vec::new()
17711            },
17712            more,
17713            bootstrap_advanced,
17714        }
17715    }
17716
17717    /// §7.7: one idle drive's default total deadline, the TS client's value.
17718    pub const SYNC_IDLE_TIMEOUT_MS: u64 = 60_000;
17719
17720    pub fn sync_until_idle(
17721        &mut self,
17722        transport: &mut dyn Transport,
17723        max_rounds: Option<u32>,
17724    ) -> SyncOutcome {
17725        self.sync_until_idle_with_deadline(transport, max_rounds, Some(Self::SYNC_IDLE_TIMEOUT_MS))
17726    }
17727
17728    /// As [`SyncClient::sync_until_idle`], with an explicit total deadline in
17729    /// milliseconds (`None` disables it). The deadline is checked between
17730    /// rounds; one blocking transport call is bounded by the transport's own
17731    /// whole-round deadline (§1.8).
17732    pub fn sync_until_idle_with_deadline(
17733        &mut self,
17734        transport: &mut dyn Transport,
17735        max_rounds: Option<u32>,
17736        timeout_ms: Option<u64>,
17737    ) -> SyncOutcome {
17738        // §7.7: an explicit budget is a strict positive round count. Zero is a
17739        // caller error, not an exhaustion.
17740        if max_rounds == Some(0) {
17741            return SyncOutcome::Failed {
17742                details: None,
17743                error_code: "sync.invalid_request".into(),
17744                message: "maxRounds must be a positive integer".into(),
17745                recommended_action: None,
17746            };
17747        }
17748        if timeout_ms.is_some_and(|ms| ms == 0 || i64::try_from(ms).is_err()) {
17749            return SyncOutcome::Failed {
17750                details: None,
17751                error_code: "sync.invalid_request".into(),
17752                message: "timeoutMs must be a positive integer".into(),
17753                recommended_action: None,
17754            };
17755        }
17756        let started_at_ms = self.clock_now_ms();
17757        let deadline_ms = timeout_ms.map(|ms| started_at_ms.saturating_add(ms as i64));
17758        self.sync_until_idle_bounded(transport, max_rounds, started_at_ms, deadline_ms)
17759    }
17760
17761    fn sync_until_idle_bounded(
17762        &mut self,
17763        transport: &mut dyn Transport,
17764        max_rounds: Option<u32>,
17765        started_at_ms: i64,
17766        deadline_ms: Option<i64>,
17767    ) -> SyncOutcome {
17768        let rounds = max_rounds.unwrap_or(20);
17769        let mut aggregate = SyncReport::default();
17770        let mut spent = 0;
17771        while spent < rounds {
17772            if let Some(deadline_ms) = deadline_ms {
17773                if self.clock_now_ms() >= deadline_ms {
17774                    return SyncOutcome::Failed {
17775                        error_code: "sync.idle_timeout".into(),
17776                        message: "sync_until_idle exceeded its deadline".into(),
17777                        details: Some(serde_json::json!({
17778                            "timeoutMs": deadline_ms
17779                                .saturating_sub(started_at_ms) as u64,
17780                            "rounds": spent,
17781                        })),
17782                        recommended_action: Some("retryLater".into()),
17783                    };
17784                }
17785            }
17786            spent += 1;
17787            let before = self
17788                .subs
17789                .iter()
17790                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17791                .collect::<Vec<_>>();
17792            match self.sync(transport) {
17793                SyncOutcome::Failed {
17794                    error_code,
17795                    message,
17796                    details,
17797                    recommended_action,
17798                } => {
17799                    return SyncOutcome::Failed {
17800                        details,
17801                        error_code,
17802                        message,
17803                        recommended_action,
17804                    };
17805                }
17806                outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
17807                SyncOutcome::BudgetExhausted(_) => {
17808                    unreachable!("sync() never exhausts a round budget")
17809                }
17810                SyncOutcome::Ok(report) => {
17811                    if max_rounds.is_none()
17812                        && report.segment_rows_applied > 0
17813                        && !report.bootstrapping.is_empty()
17814                        && before
17815                            != self
17816                                .subs
17817                                .iter()
17818                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17819                                .collect::<Vec<_>>()
17820                    {
17821                        spent = 0;
17822                    }
17823                    // TS parity: a disabled transport or a schema floor ends
17824                    // the loop. The latest round still merges first.
17825                    let stop = !self.transport_enabled() || report.schema_floor.is_some();
17826                    aggregate.merge(&report);
17827                    if stop {
17828                        return SyncOutcome::Ok(aggregate);
17829                    }
17830                    // §4.5: pull again whenever the response contained
17831                    // commits or segments; resets re-bootstrap; a pending
17832                    // resume token continues paging (§4.7); deferred outbox
17833                    // commits and a raised sync-needed signal push on the next
17834                    // round.
17835                    let more = !report.bootstrapping.is_empty()
17836                        || report.commits_applied > 0
17837                        || report.segment_rows_applied > 0
17838                        || !report.resets.is_empty()
17839                        || report.deferred_commits > 0
17840                        || self.sync_needed;
17841                    if !more {
17842                        return SyncOutcome::Ok(aggregate);
17843                    }
17844                }
17845            }
17846        }
17847        // §7.7: partial success. The aggregate report is retained and the run
17848        // is explicitly not idle.
17849        SyncOutcome::BudgetExhausted(aggregate)
17850    }
17851
17852    fn process_response(
17853        &mut self,
17854        transport: &mut dyn Transport,
17855        response: Message,
17856        meta: &RequestMeta,
17857    ) -> SyncOutcome {
17858        #[cfg(feature = "bench-internals")]
17859        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
17860        let mut report = SyncReport {
17861            pushed: meta.pushed_ids.len() as u32,
17862            ..SyncReport::default()
17863        };
17864        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
17865            HashMap::new();
17866        let pushed_ids = meta
17867            .pushed_ids
17868            .iter()
17869            .map(String::as_str)
17870            .collect::<HashSet<_>>();
17871        let mut last_final_push_result_id: Option<String> = None;
17872        for frame in &response.frames {
17873            match frame {
17874                Frame::PushResultDetails {
17875                    client_commit_id,
17876                    entries,
17877                } => {
17878                    let details = rejection_details_by_commit
17879                        .entry(client_commit_id.clone())
17880                        .or_default();
17881                    for entry in entries {
17882                        let parsed = match RejectionDetails::parse(&entry.details.0) {
17883                            Ok(value) => value,
17884                            Err(message) => {
17885                                return SyncOutcome::Failed {
17886                                    details: None,
17887                                    error_code: "sync.invalid_request".to_owned(),
17888                                    message,
17889                                    recommended_action: None,
17890                                };
17891                            }
17892                        };
17893                        details.insert(entry.op_index, parsed);
17894                    }
17895                }
17896                Frame::PushResult {
17897                    client_commit_id,
17898                    status,
17899                    results,
17900                    ..
17901                } if pushed_ids.contains(client_commit_id.as_str())
17902                    && Self::push_result_is_final(*status, results) =>
17903                {
17904                    last_final_push_result_id = Some(client_commit_id.clone());
17905                }
17906                _ => {}
17907            }
17908        }
17909        if response.wire_version < 2 {
17910            return SyncOutcome::Failed {
17911                details: None,
17912                error_code: "client.invalid_host_response".to_owned(),
17913                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
17914                recommended_action: None,
17915            };
17916        }
17917        let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
17918            status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
17919        });
17920        let mut frames = response.frames.into_iter().peekable();
17921        match frames.next() {
17922            Some(Frame::RespHeader {
17923                required_schema_version,
17924                latest_schema_version,
17925                log_epoch,
17926                reset_required,
17927            }) => {
17928                let Some(log_epoch) = log_epoch else {
17929                    return SyncOutcome::Failed {
17930                        details: None,
17931                        error_code: "client.invalid_host_response".to_owned(),
17932                        message: "response header omits logEpoch".to_owned(),
17933                        recommended_action: None,
17934                    };
17935                };
17936                let Some(reset_required) = reset_required else {
17937                    return SyncOutcome::Failed {
17938                        details: None,
17939                        error_code: "client.invalid_host_response".to_owned(),
17940                        message: "response header omits resetRequired".to_owned(),
17941                        recommended_action: None,
17942                    };
17943                };
17944                if let Some(required) = required_schema_version {
17945                    // §1.6 schema-floor response: nothing else is processed;
17946                    // stop syncing and surface the upgrade requirement.
17947                    let floor = SchemaFloor {
17948                        required_schema_version: Some(required),
17949                        latest_schema_version,
17950                    };
17951                    self.set_schema_floor(Some(floor.clone()));
17952                    report.schema_floor = Some(floor);
17953                    return SyncOutcome::Ok(report);
17954                }
17955                if reset_required {
17956                    if frames.next().is_some() {
17957                        return SyncOutcome::Failed {
17958                            details: None,
17959                            error_code: "client.invalid_host_response".to_owned(),
17960                            message: "log-epoch reset response contains body frames".to_owned(),
17961                            recommended_action: None,
17962                        };
17963                    }
17964                    match self.run_log_epoch_reset(&log_epoch) {
17965                        Ok(resets) => {
17966                            report.resets = resets;
17967                            return SyncOutcome::Ok(report);
17968                        }
17969                        Err(message) => {
17970                            return SyncOutcome::Failed {
17971                                details: None,
17972                                error_code: "sync.local_corrupt".to_owned(),
17973                                message,
17974                                recommended_action: None,
17975                            };
17976                        }
17977                    }
17978                }
17979                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
17980                    return SyncOutcome::Failed {
17981                        details: None,
17982                        error_code: "client.invalid_host_response".to_owned(),
17983                        message: "server changed logEpoch without requiring a reset".to_owned(),
17984                        recommended_action: None,
17985                    };
17986                }
17987            }
17988            _ => {
17989                return SyncOutcome::Failed {
17990                    details: None,
17991                    error_code: "sync.invalid_request".to_owned(),
17992                    message: "response does not start with RESP_HEADER".to_owned(),
17993                    recommended_action: None,
17994                };
17995            }
17996        }
17997
17998        if let Err(message) = self.begin_coalesced_apply() {
17999            return SyncOutcome::Failed {
18000                details: None,
18001                error_code: "storage.failed".into(),
18002                message,
18003                recommended_action: None,
18004            };
18005        }
18006        let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
18007        while let Some(frame) = frames.next() {
18008            match frame {
18009                frame @ Frame::PushResult { .. } => {
18010                    let successful = matches!(
18011                        &frame,
18012                        Frame::PushResult {
18013                            status: PushStatus::Applied | PushStatus::Cached,
18014                            ..
18015                        }
18016                    );
18017                    let mut results = vec![frame];
18018                    if successful {
18019                        while matches!(
18020                            frames.peek(),
18021                            Some(Frame::PushResult {
18022                                status: PushStatus::Applied | PushStatus::Cached,
18023                                ..
18024                            })
18025                        ) {
18026                            if let Some(next) = frames.next() {
18027                                results.push(next);
18028                            }
18029                        }
18030                    }
18031                    if self
18032                        .handle_push_results(
18033                            &results,
18034                            &pushed_ids,
18035                            &rejection_details_by_commit,
18036                            &mut report,
18037                            last_final_push_result_id.as_deref(),
18038                        )
18039                        .is_err()
18040                    {
18041                        failure = Some((
18042                            "client.outcome_persistence_failed".to_owned(),
18043                            "local commit outcome could not be persisted".to_owned(),
18044                            None,
18045                            None,
18046                        ));
18047                        break;
18048                    }
18049                }
18050                Frame::PushResultDetails { .. } => {}
18051                Frame::SubStart {
18052                    id,
18053                    status,
18054                    reason_code,
18055                    effective_scopes,
18056                    bootstrap: _,
18057                } => {
18058                    let mut body = Vec::new();
18059                    let mut sub_end: Option<(i64, Option<String>)> = None;
18060                    for inner in frames.by_ref() {
18061                        match inner {
18062                            Frame::SubEnd {
18063                                next_cursor,
18064                                bootstrap_state,
18065                            } => {
18066                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
18067                                break;
18068                            }
18069                            Frame::Unknown { .. } => {}
18070                            other => body.push(other),
18071                        }
18072                    }
18073                    if let Err(SectionError::Abort(code, message)) = self.process_section(
18074                        transport,
18075                        &id,
18076                        status,
18077                        &reason_code,
18078                        effective_scopes,
18079                        body,
18080                        sub_end,
18081                        meta,
18082                        &mut report,
18083                        &coalesced_deletes,
18084                    ) {
18085                        failure = Some((code, message, None, None));
18086                        break;
18087                    }
18088                }
18089                Frame::Lease {
18090                    lease_id,
18091                    expires_at_ms,
18092                } => {
18093                    // §7.3.5: persist the opaque lease; a fresh lease clears
18094                    // any prior lease error (the outage/revocation is over).
18095                    self.set_lease_state(Some(LeaseState {
18096                        lease_id: Some(lease_id),
18097                        expires_at_ms: Some(expires_at_ms),
18098                        error_code: None,
18099                    }));
18100                }
18101                Frame::Error {
18102                    code,
18103                    message,
18104                    recommended_action,
18105                    details,
18106                    ..
18107                } => {
18108                    // §1.6: the whole request failed; already-completed
18109                    // subscriptions keep their applied data and cursors.
18110                    let recommended_action =
18111                        (!recommended_action.is_empty()).then_some(recommended_action);
18112                    let details = details
18113                        .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
18114                        .filter(Value::is_object);
18115                    failure = Some((code, message, recommended_action, details));
18116                    break;
18117                }
18118                Frame::Unknown { .. } => {}
18119                _ => {
18120                    failure = Some((
18121                        "sync.invalid_request".to_owned(),
18122                        "unexpected frame in response".to_owned(),
18123                        None,
18124                        None,
18125                    ));
18126                    break;
18127                }
18128            }
18129        }
18130
18131        let applied = match failure {
18132            Some(failure) => Err(failure),
18133            // Local apply boundaries already reconciled their affected overlay.
18134            // Refuse round success if any remaining dirty work fails to
18135            // reconcile. §4.8 E1: the push half may have drained commits that
18136            // pinned rows of a shrunk window unit, so retry deferred evictions.
18137            None => self
18138                .rebuild_overlay_if_dirty()
18139                .and_then(|()| self.drain_pending_evictions())
18140                .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
18141        };
18142        // §1.4 rule 4: frames completed before a failure stay committed; a
18143        // failed COMMIT of the coalesced transaction fails the round.
18144        if let Err(message) = self.finish_coalesced_apply() {
18145            return SyncOutcome::Failed {
18146                details: None,
18147                error_code: "storage.failed".into(),
18148                message,
18149                recommended_action: None,
18150            };
18151        }
18152        if let Err((error_code, message, recommended_action, details)) = applied {
18153            return SyncOutcome::Failed {
18154                details,
18155                error_code,
18156                message,
18157                recommended_action,
18158            };
18159        }
18160        self.ack_after_pull(transport);
18161        // §7.4.5: the reset is over once the first post-reset pull round
18162        // leaves no subscription mid-bootstrap — the tables are rebuilt.
18163        if self.upgrading && report.bootstrapping.is_empty() {
18164            self.set_upgrading(false);
18165        }
18166        SyncOutcome::Ok(report)
18167    }
18168
18169    // -- push results (§6.3, §7.2) ------------------------------------------------
18170
18171    fn handle_push_results(
18172        &mut self,
18173        frames: &[Frame],
18174        pushed_ids: &HashSet<&str>,
18175        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
18176        report: &mut SyncReport,
18177        last_final_push_result_id: Option<&str>,
18178    ) -> Result<(), String> {
18179        self.begin_observation("syncular_push_results")?;
18180        let previous_failed = self.failed_commits.clone();
18181        let previous_sync_needed = self.sync_needed;
18182        let conflict_count = self.conflicts.len();
18183        let rejection_count = self.rejections.len();
18184        let applied_count = report.applied.len();
18185        let rejected_count = report.rejected.len();
18186        let retryable_count = report.retryable.len();
18187        let was_dirty = self.overlay_dirty.snapshot();
18188        let mut removed = Vec::new();
18189        let mut batch = ChangeAccumulator::default();
18190        let persisted = (|| {
18191            for frame in frames {
18192                let Frame::PushResult {
18193                    client_commit_id,
18194                    status,
18195                    results,
18196                    commit_seq,
18197                } = frame
18198                else {
18199                    return Err("unexpected acknowledgement frame".to_owned());
18200                };
18201                if !pushed_ids.contains(client_commit_id.as_str()) {
18202                    continue;
18203                }
18204                let Some(index) = self
18205                    .outbox
18206                    .iter()
18207                    .position(|commit| commit.client_commit_id == *client_commit_id)
18208                else {
18209                    continue;
18210                };
18211                let status = *status;
18212                let rejection_details = rejection_details_by_commit.get(client_commit_id);
18213                let operations = self.outbox[index].ops.clone();
18214                match status {
18215                    PushStatus::Applied | PushStatus::Cached => {
18216                        // §7.2: a lost ack replays as `cached` — proceed as if the
18217                        // ack had arrived.
18218                        let sequence =
18219                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
18220                        let affected: HashSet<_> = operations
18221                            .iter()
18222                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
18223                            .collect();
18224                        let prior = self.acknowledged_operations(Some(&affected), None)?;
18225                        for (op_index, operation) in operations.iter().enumerate() {
18226                            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))
18227                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18228                            if delivered {
18229                                continue;
18230                            }
18231                            let table = self
18232                                .schema
18233                                .table(&operation.table)
18234                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
18235                            let mut intended: Map<String, Value> = self
18236                                .stored_row(table, &operation.row_id, true)?
18237                                .map(|(row, _)| {
18238                                    table
18239                                        .columns
18240                                        .iter()
18241                                        .zip(&row)
18242                                        .map(|(column, value)| {
18243                                            (column.name.clone(), column_value_to_json(value))
18244                                        })
18245                                        .collect()
18246                                })
18247                                .unwrap_or_default();
18248                            let mut scope_image = intended.clone();
18249                            for op in prior
18250                                .iter()
18251                                .chain(
18252                                    self.failed_commits
18253                                        .iter()
18254                                        .flat_map(|(commit, _)| &commit.ops),
18255                                )
18256                                .chain(
18257                                    self.outbox
18258                                        .iter()
18259                                        .take(index)
18260                                        .flat_map(|commit| &commit.ops),
18261                                )
18262                                .chain(operations.iter().take(op_index + 1))
18263                            {
18264                                if op.table != operation.table || op.row_id != operation.row_id {
18265                                    continue;
18266                                }
18267                                if let Some(values) = &op.values {
18268                                    intended.extend(values.clone());
18269                                } else {
18270                                    scope_image = intended.clone();
18271                                    intended.clear();
18272                                }
18273                            }
18274                            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)",
18275                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
18276                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
18277                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
18278                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18279                        }
18280                        let journal_results = results
18281                            .iter()
18282                            .map(|result| {
18283                                let op_index = match result {
18284                                    OpResult::Applied { op_index }
18285                                    | OpResult::Conflict { op_index, .. }
18286                                    | OpResult::Error { op_index, .. } => *op_index,
18287                                };
18288                                CommitOperationOutcome::Applied { op_index }
18289                            })
18290                            .collect::<Vec<_>>();
18291                        let outcome_status = if status == PushStatus::Applied {
18292                            CommitOutcomeStatus::Applied
18293                        } else {
18294                            CommitOutcomeStatus::Cached
18295                        };
18296                        let persisted = self
18297                            .persist_commit_outcome(
18298                                client_commit_id,
18299                                outcome_status,
18300                                &journal_results,
18301                                None,
18302                            )
18303                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18304                        persisted?;
18305                        for operation in &operations {
18306                            self.overlay_dirty.table(&operation.table);
18307                        }
18308                        self.sync_needed = true;
18309                        batch.status = true;
18310                        batch.outcomes = true;
18311                    }
18312                    PushStatus::Rejected => {
18313                        if results.iter().any(|result| {
18314                            matches!(
18315                                result,
18316                                OpResult::Error {
18317                                    code,
18318                                    retryable: true,
18319                                    ..
18320                                } if code == "sync.idempotency_cache_miss"
18321                            )
18322                        }) {
18323                            // §6.3/§7.2: a serving failure, not an outcome — keep the
18324                            // exact commit queued for an identical retry.
18325                            report.retryable.push(client_commit_id.to_owned());
18326                            continue;
18327                        }
18328
18329                        let mut journal_results = Vec::with_capacity(results.len());
18330                        let mut conflicts = Vec::new();
18331                        let mut rejections = Vec::new();
18332                        for result in results {
18333                            match result {
18334                                OpResult::Applied { op_index } => {
18335                                    journal_results.push(CommitOperationOutcome::Applied {
18336                                        op_index: *op_index,
18337                                    });
18338                                }
18339                                OpResult::Conflict {
18340                                    op_index,
18341                                    code,
18342                                    message,
18343                                    server_version,
18344                                    server_row,
18345                                    conflict_columns: conflict_columns_bitmap,
18346                                } => {
18347                                    let operation = operations
18348                                        .get(*op_index as usize)
18349                                        .map(CommitOperation::from);
18350                                    let (table, row_id) = operation
18351                                        .as_ref()
18352                                        .map(|op| (op.table.clone(), op.row_id.clone()))
18353                                        .unwrap_or_default();
18354                                    let server_row_json = self
18355                                        .schema
18356                                        .table(&table)
18357                                        .and_then(|t| {
18358                                            decode_row_bytes(t, server_row, &self.encryption)
18359                                                .ok()
18360                                                .map(|row| (t, row))
18361                                        })
18362                                        .map(|(t, row)| {
18363                                            let mut map = Map::new();
18364                                            for (i, column) in t.columns.iter().enumerate() {
18365                                                map.insert(
18366                                                    column.name.clone(),
18367                                                    column_value_to_json(
18368                                                        row.get(i).unwrap_or(&None),
18369                                                    ),
18370                                                );
18371                                            }
18372                                            map
18373                                        })
18374                                        .unwrap_or_default();
18375                                    // §6.3: decode the presence-layout bitmap
18376                                    // into the marked column names.
18377                                    let conflict_columns = self
18378                                        .schema
18379                                        .table(&table)
18380                                        .map(|t| {
18381                                            t.columns
18382                                                .iter()
18383                                                .enumerate()
18384                                                .filter(|(index, _)| {
18385                                                    conflict_columns_bitmap
18386                                                        .get(index / 8)
18387                                                        .is_some_and(|byte| {
18388                                                            byte & (1 << (index % 8)) != 0
18389                                                        })
18390                                                })
18391                                                .map(|(_, column)| column.name.clone())
18392                                                .collect::<Vec<_>>()
18393                                        })
18394                                        .unwrap_or_default();
18395                                    let conflict = ConflictRecord {
18396                                        client_commit_id: client_commit_id.to_owned(),
18397                                        op_index: *op_index,
18398                                        table,
18399                                        row_id,
18400                                        code: code.clone(),
18401                                        message: message.clone(),
18402                                        server_version: *server_version,
18403                                        server_row: server_row_json,
18404                                        conflict_columns,
18405                                        operation,
18406                                    };
18407                                    journal_results.push(CommitOperationOutcome::Conflict {
18408                                        conflict: conflict.clone(),
18409                                    });
18410                                    conflicts.push(conflict);
18411                                }
18412                                OpResult::Error {
18413                                    op_index,
18414                                    code,
18415                                    message,
18416                                    retryable,
18417                                } => {
18418                                    let rejection = RejectionRecord {
18419                                        client_commit_id: client_commit_id.to_owned(),
18420                                        op_index: *op_index,
18421                                        code: code.clone(),
18422                                        message: message.clone(),
18423                                        retryable: *retryable,
18424                                        details: rejection_details
18425                                            .and_then(|details| details.get(op_index))
18426                                            .cloned(),
18427                                        operation: operations
18428                                            .get(*op_index as usize)
18429                                            .map(CommitOperation::from),
18430                                    };
18431                                    journal_results.push(CommitOperationOutcome::Error {
18432                                        rejection: rejection.clone(),
18433                                    });
18434                                    rejections.push(rejection);
18435                                }
18436                            }
18437                        }
18438                        if self.retain_failed_commits {
18439                            self.retain_failed_commit(client_commit_id, &operations)?;
18440                        }
18441                        let outcome_status = if conflicts.is_empty() {
18442                            CommitOutcomeStatus::Rejected
18443                        } else {
18444                            CommitOutcomeStatus::Conflict
18445                        };
18446                        let persisted = self
18447                            .persist_commit_outcome(
18448                                client_commit_id,
18449                                outcome_status,
18450                                &journal_results,
18451                                Some(&operations),
18452                            )
18453                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18454                        persisted?;
18455                        batch.conflicts = !conflicts.is_empty();
18456                        batch.rejections = !rejections.is_empty();
18457                        batch.status = true;
18458                        batch.outcomes = true;
18459                        self.conflicts.extend(conflicts);
18460                        self.rejections.extend(rejections);
18461                        for operation in &operations {
18462                            self.overlay_dirty.table(&operation.table);
18463                        }
18464                    }
18465                }
18466                removed.push((index, self.outbox.remove(index)));
18467                if batch.status {
18468                    for operation in &operations {
18469                        if !self.record_row_scopes(
18470                            &mut batch,
18471                            &operation.table,
18472                            &operation.row_id,
18473                            false,
18474                        ) {
18475                            batch.table(&operation.table);
18476                        }
18477                    }
18478                }
18479                match status {
18480                    PushStatus::Applied | PushStatus::Cached => {
18481                        report.applied.push(client_commit_id.to_owned())
18482                    }
18483                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
18484                }
18485            }
18486            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)) {
18487                self.prune_commit_outcomes()?;
18488            }
18489            self.rebuild_overlay_if_dirty()?;
18490            self.finish_observation("syncular_push_results", batch)
18491        })();
18492        if let Err(error) = persisted {
18493            self.rollback_observation("syncular_push_results");
18494            self.failed_commits = previous_failed;
18495            self.sync_needed = previous_sync_needed;
18496            for (index, commit) in removed.into_iter().rev() {
18497                self.outbox.insert(index, commit);
18498            }
18499            self.conflicts.truncate(conflict_count);
18500            self.rejections.truncate(rejection_count);
18501            self.overlay_dirty.restore(was_dirty.clone());
18502            report.applied.truncate(applied_count);
18503            report.rejected.truncate(rejected_count);
18504            report.retryable.truncate(retryable_count);
18505            return Err(error);
18506        }
18507        if !previous_sync_needed && self.sync_needed {
18508            self.sync_intent_queue.push_back(SyncIntent::Interactive);
18509        }
18510        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
18511        Ok(())
18512    }
18513
18514    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
18515        status != PushStatus::Rejected
18516            || !results.iter().any(|result| {
18517                matches!(
18518                    result,
18519                    OpResult::Error {
18520                        code,
18521                        retryable: true,
18522                        ..
18523                    } if code == "sync.idempotency_cache_miss"
18524                )
18525            })
18526    }
18527
18528    // -- subscription sections ------------------------------------------------------
18529
18530    #[allow(clippy::too_many_arguments)]
18531    fn process_section(
18532        &mut self,
18533        transport: &mut dyn Transport,
18534        id: &str,
18535        status: SubStatus,
18536        reason_code: &str,
18537        effective_scopes: Vec<(String, Vec<String>)>,
18538        body: Vec<Frame>,
18539        sub_end: Option<(i64, Option<String>)>,
18540        meta: &RequestMeta,
18541        report: &mut SyncReport,
18542        coalesced_deletes: &HashSet<(i64, String, String)>,
18543    ) -> Result<(), SectionError> {
18544        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
18545            return Ok(()); // unknown echo: ignore
18546        };
18547        if status != SubStatus::Active && sub_end.is_none() {
18548            return Err(SectionError::Abort(
18549                "sync.invalid_request".into(),
18550                "subscription section without SUB_END".into(),
18551            ));
18552        }
18553        match status {
18554            SubStatus::Revoked => {
18555                self.begin_observation("syncular_revocation")
18556                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18557                let previous = self.subs[sub_index].clone();
18558                let was_dirty = self.overlay_dirty.snapshot();
18559                let previous_outbox = self.outbox.clone();
18560                let previous_failed = self.failed_commits.clone();
18561                let previous_rejections = self.rejections.clone();
18562                let previous_conflicts = self.conflicts.clone();
18563                let revoked_count = report.revoked.len();
18564                let failed_count = report.failed.len();
18565                let result = (|| {
18566                    let mut batch = ChangeAccumulator::default();
18567                    let registered = self.window_unit_by_sub_id(id);
18568                    // §3.3: stop pulling, purge exactly the last effective grant.
18569                    let (table, effective) = {
18570                        let sub = &self.subs[sub_index];
18571                        (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18572                    };
18573                    let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
18574                        SectionError::Abort(
18575                            "sync.unknown_table".to_owned(),
18576                            "revoked scope references an unknown table".to_owned(),
18577                        )
18578                    })?;
18579                    let dropped = self
18580                        .drop_failed_commits(|name, values| {
18581                            name == table
18582                                && !effective.is_empty()
18583                                && effective.iter().all(|(variable, allowed)| {
18584                                    scoped_table
18585                                        .scope_column(variable)
18586                                        .and_then(|column| values.get(column))
18587                                        .and_then(Value::as_str)
18588                                        .is_some_and(|value| {
18589                                            allowed.iter().any(|allowed| allowed == value)
18590                                        })
18591                                })
18592                        })
18593                        .map_err(|message| {
18594                            SectionError::Abort("storage.failed".to_owned(), message)
18595                        })?;
18596                    for commit in dropped {
18597                        batch.conflicts = true;
18598                        batch.rejections = true;
18599                        batch.outcomes = true;
18600                        for operation in commit.ops {
18601                            batch.table(&operation.table);
18602                        }
18603                    }
18604                    let purged = self.purge_scope_rows(&table, &effective);
18605                    match purged {
18606                        Ok(()) => {
18607                            self.record_scope_map(&mut batch, &table, &effective);
18608                            let sub = &mut self.subs[sub_index];
18609                            sub.state = SubState::Revoked;
18610                            sub.reason_code = Some(if reason_code.is_empty() {
18611                                "sync.scope_revoked".to_owned()
18612                            } else {
18613                                reason_code.to_owned()
18614                            });
18615                            report.revoked.push(id.to_owned());
18616                            let doomed_effective = effective;
18617                            let sub_table = table;
18618                            self.persist_sub(&self.subs[sub_index].clone())
18619                                .map_err(|message| {
18620                                    SectionError::Abort("storage.failed".into(), message)
18621                                })?;
18622                            let dropped = self
18623                                .drop_doomed_outbox(&sub_table, &doomed_effective)
18624                                .map_err(|message| {
18625                                    SectionError::Abort("storage.failed".to_owned(), message)
18626                                })?;
18627                            if dropped {
18628                                batch.status = true;
18629                                batch.rejections = true;
18630                                batch.outcomes = true;
18631                            }
18632                            // §5.9.7 B2: revocation deletes now-unauthorized blob
18633                            // bodies (evicted ≠ revoked).
18634                            self.delete_unreferenced_cached_blobs().map_err(|message| {
18635                                SectionError::Abort("storage.failed".into(), message)
18636                            })?;
18637                        }
18638                        Err(()) => {
18639                            // §3.3 fail closed: no local mapping — never clear by
18640                            // approximation; fatal configuration error.
18641                            let sub = &mut self.subs[sub_index];
18642                            sub.state = SubState::Failed;
18643                            sub.reason_code = Some("sync.scope_revoked".to_owned());
18644                            report.failed.push(id.to_owned());
18645                            self.persist_sub(&self.subs[sub_index].clone())
18646                                .map_err(|message| {
18647                                    SectionError::Abort("storage.failed".into(), message)
18648                                })?;
18649                        }
18650                    }
18651                    if let Some((base_key, unit)) = registered {
18652                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
18653                    }
18654                    self.rebuild_overlay_if_dirty()
18655                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18656                    self.finish_observation("syncular_revocation", batch)
18657                        .map_err(|message| {
18658                            SectionError::Abort("storage.failed".to_owned(), message)
18659                        })?;
18660                    Ok(())
18661                })();
18662                if result.is_err() {
18663                    self.rollback_observation("syncular_revocation");
18664                    self.subs[sub_index] = previous;
18665                    self.overlay_dirty.restore(was_dirty.clone());
18666                    self.outbox = previous_outbox;
18667                    self.failed_commits = previous_failed;
18668                    self.rejections = previous_rejections;
18669                    self.conflicts = previous_conflicts;
18670                    report.revoked.truncate(revoked_count);
18671                    report.failed.truncate(failed_count);
18672                }
18673                result
18674            }
18675            SubStatus::Reset => {
18676                self.begin_observation("syncular_reset")
18677                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18678                let previous = self.subs[sub_index].clone();
18679                let was_dirty = self.overlay_dirty.snapshot();
18680                let reset_count = report.resets.len();
18681                let result = (|| {
18682                    let mut batch = ChangeAccumulator::default();
18683                    let registered = self.window_unit_by_sub_id(id);
18684                    // §4.6: discard cursor + bootstrap state, keep local rows —
18685                    // reset is a staleness signal, not a purge signal.
18686                    let sub = &mut self.subs[sub_index];
18687                    sub.cursor = -1;
18688                    sub.bootstrap_state = None;
18689                    sub.reason_code = Some(if reason_code.is_empty() {
18690                        "sync.cursor_expired".to_owned()
18691                    } else {
18692                        reason_code.to_owned()
18693                    });
18694                    report.resets.push(id.to_owned());
18695                    self.persist_sub(&self.subs[sub_index].clone())
18696                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18697                    if let Some((base_key, unit)) = registered {
18698                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
18699                    }
18700                    self.finish_observation("syncular_reset", batch)
18701                        .map_err(|message| {
18702                            SectionError::Abort("storage.failed".to_owned(), message)
18703                        })?;
18704                    Ok(())
18705                })();
18706                if result.is_err() {
18707                    self.rollback_observation("syncular_reset");
18708                    self.subs[sub_index] = previous;
18709                    self.overlay_dirty.restore(was_dirty.clone());
18710                    report.resets.truncate(reset_count);
18711                }
18712                result
18713            }
18714            SubStatus::Active => {
18715                let fresh = meta
18716                    .fresh
18717                    .iter()
18718                    .find(|(fid, _)| fid == id)
18719                    .map(|(_, fresh)| *fresh)
18720                    .unwrap_or(false);
18721                let previous = self.subs[sub_index].clone();
18722                self.subs[sub_index].effective = Some(effective_scopes);
18723                let outcome = self
18724                    .apply_section_body(
18725                        transport,
18726                        sub_index,
18727                        body,
18728                        fresh,
18729                        meta,
18730                        report,
18731                        coalesced_deletes,
18732                    )
18733                    .and_then(|()| {
18734                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
18735                            SectionError::Abort(
18736                                "sync.invalid_request".into(),
18737                                "subscription section without SUB_END".into(),
18738                            )
18739                        })?;
18740                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
18741                            .map_err(|message| {
18742                                SectionError::Abort("storage.failed".into(), message)
18743                            })
18744                    });
18745                match outcome {
18746                    Ok(()) => {
18747                        if self.subs[sub_index].bootstrap_state.is_some() {
18748                            report.bootstrapping.push(id.to_owned());
18749                        }
18750                        Ok(())
18751                    }
18752                    Err(SectionError::FailClosed) => {
18753                        self.subs[sub_index] = previous.clone();
18754                        self.begin_observation("syncular_section_failure")
18755                            .map_err(|message| {
18756                                SectionError::Abort("storage.failed".into(), message)
18757                            })?;
18758                        let persisted = (|| {
18759                            let mut batch = ChangeAccumulator::default();
18760                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
18761                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
18762                            }
18763                            self.subs[sub_index].state = SubState::Failed;
18764                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
18765                            self.persist_sub(&self.subs[sub_index])?;
18766                            self.finish_observation("syncular_section_failure", batch)
18767                        })();
18768                        if let Err(message) = persisted {
18769                            self.rollback_observation("syncular_section_failure");
18770                            self.subs[sub_index] = previous;
18771                            return Err(SectionError::Abort("storage.failed".into(), message));
18772                        }
18773                        report.failed.push(id.to_owned());
18774                        Ok(())
18775                    }
18776                    Err(error) => {
18777                        // Frame/block transactions already completed. Preserve their
18778                        // rows and revisions, but leave this subscription cursor unchanged.
18779                        self.subs[sub_index] = previous;
18780                        Err(error)
18781                    }
18782                }
18783            }
18784        }
18785    }
18786
18787    fn apply_sub_end(
18788        &mut self,
18789        sub_index: usize,
18790        cursor: i64,
18791        bootstrap_state: Option<String>,
18792    ) -> Result<(), String> {
18793        #[cfg(feature = "bench-internals")]
18794        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
18795        let previous = self.subs[sub_index].clone();
18796        self.begin_observation("syncular_sub_end")?;
18797        let persisted = (|| {
18798            let mut batch = ChangeAccumulator::default();
18799            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
18800                && cursor >= 0
18801                && bootstrap_state.is_none();
18802            if completed {
18803                let table = self
18804                    .schema
18805                    .table(&previous.table)
18806                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
18807                let effective = previous.effective.as_deref().unwrap_or(&[]);
18808                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
18809                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18810                let rows = statement
18811                    .query_map(rusqlite::params![previous.table, cursor], |row| {
18812                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18813                    })
18814                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18815                let mut covered = HashSet::new();
18816                for row in rows {
18817                    let (id, intent) =
18818                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18819                    let values: Map<String, Value> = serde_json::from_str(&intent)
18820                        .map_err(|_| "sync.local_corrupt".to_owned())?;
18821                    if !effective.is_empty()
18822                        && effective.iter().all(|(variable, allowed)| {
18823                            table
18824                                .scope_column(variable)
18825                                .and_then(|column| values.get(column))
18826                                .is_some_and(|value| {
18827                                    allowed.contains(
18828                                        &value
18829                                            .as_str()
18830                                            .map_or_else(|| value.to_string(), str::to_owned),
18831                                    )
18832                                })
18833                        })
18834                    {
18835                        covered.insert(id);
18836                    }
18837                }
18838                drop(statement);
18839                for id in covered {
18840                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
18841                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18842                    self.overlay_dirty.table(&previous.table);
18843                    batch.table(&previous.table);
18844                }
18845                self.rebuild_overlay_if_dirty()?;
18846                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
18847                    batch.window(&base_key, &previous.table, &unit);
18848                }
18849            }
18850            let sub = &mut self.subs[sub_index];
18851            sub.cursor = cursor;
18852            sub.bootstrap_state = bootstrap_state;
18853            // §4.6: a completed (or continuing) active end clears the reset /
18854            // revocation reason, matching the TS core's active-state persist.
18855            sub.reason_code = None;
18856            sub.synced_once = true;
18857            self.persist_sub(&self.subs[sub_index])?;
18858            self.finish_observation("syncular_sub_end", batch)
18859        })();
18860        if persisted.is_err() {
18861            self.rollback_observation("syncular_sub_end");
18862            self.subs[sub_index] = previous;
18863        }
18864        persisted
18865    }
18866
18867    #[allow(clippy::too_many_arguments)]
18868    fn apply_section_body(
18869        &mut self,
18870        transport: &mut dyn Transport,
18871        sub_index: usize,
18872        body: Vec<Frame>,
18873        fresh: bool,
18874        meta: &RequestMeta,
18875        report: &mut SyncReport,
18876        coalesced_deletes: &HashSet<(i64, String, String)>,
18877    ) -> Result<(), SectionError> {
18878        let mut saw_segment = false;
18879        for frame in body {
18880            match frame {
18881                Frame::Commit {
18882                    tables,
18883                    changes,
18884                    commit_seq,
18885                    ..
18886                } => {
18887                    if self
18888                        .apply_commit_frame_coalesced(
18889                            &tables,
18890                            changes,
18891                            commit_seq,
18892                            coalesced_deletes,
18893                        )
18894                        .map_err(|(c, m)| SectionError::Abort(c, m))?
18895                    {
18896                        report.commits_applied += 1;
18897                    }
18898                }
18899                Frame::SegmentInline { payload } => {
18900                    let segment = decode_rows_segment(&payload)
18901                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
18902                    self.progress.update(|p| {
18903                        p.phase = ProgressPhase::Import;
18904                        p.subscription_id = Some(self.subs[sub_index].id.clone());
18905                        p.table = Some(self.subs[sub_index].table.clone());
18906                        p.segment_id = None;
18907                        p.bytes_received = payload.len() as u64;
18908                        p.bytes_total = Some(payload.len() as u64);
18909                        p.rows_processed = 0;
18910                        p.rows_total =
18911                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
18912                    });
18913                    let first = !saw_segment;
18914                    saw_segment = true;
18915                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18916                    report.segment_rows_applied += applied;
18917                }
18918                Frame::SegmentRef {
18919                    segment_id,
18920                    byte_length,
18921                    media_type,
18922                    table,
18923                    row_count,
18924                    as_of_commit_seq,
18925                    scope_digest,
18926                    row_cursor,
18927                    next_row_cursor,
18928                    url,
18929                    url_expires_at_ms,
18930                    ..
18931                } => {
18932                    // §4.2: reject a descriptor whose mediaType was not
18933                    // advertised — never skip or guess.
18934                    let advertised = match media_type {
18935                        MediaType::Rows => {
18936                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
18937                                || meta.accept & ACCEPT_INLINE_ROWS != 0
18938                        }
18939                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
18940                    };
18941                    if !advertised {
18942                        return Err(SectionError::Abort(
18943                            "sync.invalid_request".to_owned(),
18944                            format!(
18945                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
18946                                media_type.name()
18947                            ),
18948                        ));
18949                    }
18950                    self.progress.update(|p| {
18951                        p.phase = ProgressPhase::Download;
18952                        p.subscription_id = Some(self.subs[sub_index].id.clone());
18953                        p.table = Some(table.clone());
18954                        p.segment_id = Some(segment_id.clone());
18955                        p.bytes_received = 0;
18956                        p.bytes_total = u64::try_from(byte_length).ok();
18957                        p.rows_processed = 0;
18958                        p.rows_total = u64::try_from(row_count).ok();
18959                    });
18960                    let observer = self.progress.clone();
18961                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
18962                    let bytes = if let Some(url) = url {
18963                        // §5.4: a url-carrying descriptor MUST be fetched
18964                        // from that URL; failure invalidates the whole
18965                        // descriptor (no fall-through to §5.5 — re-pull
18966                        // recovers, §1.4 rule 5).
18967                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
18968                            return Err(SectionError::Abort(
18969                                "sync.invalid_request".to_owned(),
18970                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
18971                                    .to_owned(),
18972                            ));
18973                        }
18974                        // §5.4: MUST NOT start a fetch at/past expiry.
18975                        if url_expires_at_ms.is_some_and(|exp| {
18976                            exp <= transport
18977                                .segment_fetch_started_at(&url)
18978                                .unwrap_or_else(|| self.clock_now_ms())
18979                        }) {
18980                            return Err(SectionError::Abort(
18981                                "sync.segment_expired".to_owned(),
18982                                format!(
18983                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
18984                                ),
18985                            ));
18986                        }
18987                        transport
18988                            .fetch_url(&url, &mut on_progress)
18989                            .map_err(|e| self.segment_fetch_failed(e))?
18990                    } else {
18991                        let requested_scopes_json =
18992                            canonical_scope_json(&self.subs[sub_index].requested);
18993                        transport
18994                            .download_segment(
18995                                &SegmentRequest {
18996                                    segment_id: segment_id.clone(),
18997                                    table,
18998                                    requested_scopes_json,
18999                                },
19000                                &mut on_progress,
19001                            )
19002                            .map_err(|e| self.segment_fetch_failed(e))?
19003                    };
19004                    // §5.1: verify the content address before applying.
19005                    on_progress(bytes.len() as u64);
19006                    let digest = Sha256::digest(&bytes);
19007                    let expected = segment_id
19008                        .strip_prefix("sha256:")
19009                        .unwrap_or(segment_id.as_str());
19010                    if bytes_to_hex(&digest) != expected {
19011                        // §5.1: the client discards the segment and re-pulls.
19012                        self.schedule_background_retry();
19013                        return Err(SectionError::Abort(
19014                            "sync.invalid_request".to_owned(),
19015                            "segment bytes do not match the content address (§5.1)".to_owned(),
19016                        ));
19017                    }
19018                    self.progress.update(|p| p.phase = ProgressPhase::Import);
19019                    if media_type == MediaType::Sqlite {
19020                        // §5.3: images are whole-table — a paged sqlite
19021                        // descriptor is invalid.
19022                        if row_cursor.is_some() || next_row_cursor.is_some() {
19023                            return Err(SectionError::Abort(
19024                                "sync.invalid_request".to_owned(),
19025                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
19026                                    .to_owned(),
19027                            ));
19028                        }
19029                        let first = !saw_segment;
19030                        saw_segment = true;
19031                        let applied = self.apply_sqlite_segment(
19032                            sub_index,
19033                            &bytes,
19034                            fresh && first,
19035                            row_count,
19036                            as_of_commit_seq,
19037                            &scope_digest,
19038                        )?;
19039                        report.segment_rows_applied += applied;
19040                    } else {
19041                        let segment = decode_rows_segment(&bytes).map_err(|e| {
19042                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
19043                        })?;
19044                        let first = row_cursor.is_none();
19045                        saw_segment = true;
19046                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
19047                        report.segment_rows_applied += applied;
19048                    }
19049                }
19050                Frame::Unknown { .. } => {}
19051                Frame::Error { code, message, .. } => {
19052                    return Err(SectionError::Abort(code, message))
19053                }
19054                _ => {
19055                    return Err(SectionError::Abort(
19056                        "sync.invalid_request".to_owned(),
19057                        "unexpected frame inside a subscription section".to_owned(),
19058                    ));
19059                }
19060            }
19061        }
19062        Ok(())
19063    }
19064
19065    /// Apply one commit frame after dropping a scope move's duplicate delete
19066    /// when the same commit carries the row's upsert in another applied
19067    /// section (§2.2). A frame that carried both itself is untouched.
19068    fn apply_commit_frame_coalesced(
19069        &mut self,
19070        tables: &[String],
19071        changes: Vec<ssp2::model::Change>,
19072        commit_seq: i64,
19073        coalesced_deletes: &HashSet<(i64, String, String)>,
19074    ) -> Result<bool, (String, String)> {
19075        let changes: Vec<ssp2::model::Change> = changes
19076            .into_iter()
19077            .filter(|change| {
19078                if change.op != Op::Delete {
19079                    return true;
19080                }
19081                let Some(table) = tables.get(change.table_index as usize) else {
19082                    return true;
19083                };
19084                !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
19085            })
19086            .collect();
19087        if changes.is_empty() {
19088            return Ok(false);
19089        }
19090        self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
19091        Ok(true)
19092    }
19093
19094    // Both pull and realtime frames use the same durable observation boundary.
19095    fn apply_commit_frame(
19096        &mut self,
19097        tables: &[String],
19098        changes: &[ssp2::model::Change],
19099        commit_seq: Option<i64>,
19100    ) -> Result<(), (String, String)> {
19101        #[cfg(feature = "bench-internals")]
19102        let _phase = self.benchmark_phases.start(Phase::CommitApply);
19103        let was_dirty = self.overlay_dirty.snapshot();
19104        self.coalesce_unit(changes.len())
19105            .and_then(|()| self.begin_observation("syncular_commit"))
19106            .map_err(|message| ("storage.failed".into(), message))?;
19107        let applied = (|| {
19108            let mut batch = ChangeAccumulator::default();
19109            self.record_commit_changes(&mut batch, tables, changes);
19110            if let Some(sequence) = commit_seq {
19111                for change in changes {
19112                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
19113                        (
19114                            "sync.invalid_request".to_owned(),
19115                            "change tableIndex out of range".to_owned(),
19116                        )
19117                    })?;
19118                    if self
19119                        .outbox
19120                        .iter()
19121                        .flat_map(|commit| &commit.ops)
19122                        .any(|op| op.table == *table && op.row_id == change.row_id)
19123                    {
19124                        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])
19125                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
19126                    }
19127                    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])
19128                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
19129                }
19130            }
19131            self.apply_commit_changes(tables, changes)?;
19132            self.rebuild_overlay_if_dirty()
19133                .map_err(|message| ("storage.failed".into(), message))?;
19134            self.finish_observation("syncular_commit", batch)
19135                .map_err(|message| ("storage.failed".into(), message))
19136        })();
19137        if applied.is_err() {
19138            self.rollback_observation("syncular_commit");
19139            self.overlay_dirty.restore(was_dirty.clone());
19140        }
19141        applied
19142    }
19143
19144    fn apply_commit_changes(
19145        &mut self,
19146        tables: &[String],
19147        changes: &[ssp2::model::Change],
19148    ) -> Result<(), (String, String)> {
19149        // A clean overlay can reconcile changed primary keys independently
19150        // when their tables have no secondary unique constraints. Such a
19151        // constraint can change whether another row's pending write succeeds,
19152        // so those frames still require the complete FIFO replay below.
19153        let mirror_visible = !self.overlay_dirty.get()
19154            && self.failed_commits.is_empty()
19155            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
19156                || changes.iter().all(|change| {
19157                    tables
19158                        .get(change.table_index as usize)
19159                        .and_then(|name| self.schema.table(name))
19160                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
19161                }));
19162        for change in changes {
19163            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
19164                (
19165                    "sync.invalid_request".to_owned(),
19166                    "change tableIndex out of range".to_owned(),
19167                )
19168            })?;
19169            let table = self.schema.table(table_name).ok_or_else(|| {
19170                (
19171                    "sync.schema_mismatch".to_owned(),
19172                    format!("change targets unknown table {table_name:?}"),
19173                )
19174            })?;
19175            match change.op {
19176                Op::Upsert => {
19177                    let payload = change.row.as_ref().ok_or_else(|| {
19178                        (
19179                            "sync.invalid_request".to_owned(),
19180                            "upsert change without row payload".to_owned(),
19181                        )
19182                    })?;
19183                    // §5.11: decrypt encrypted columns on apply.
19184                    let row = {
19185                        #[cfg(feature = "bench-internals")]
19186                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
19187                        decode_row_bytes(table, payload, &self.encryption)
19188                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
19189                    };
19190                    let version = change.row_version.unwrap_or(0);
19191                    let table_name = table.name.clone();
19192                    self.write_base_row(&table_name, &row, version)
19193                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19194                    if mirror_visible {
19195                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
19196                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19197                    }
19198                }
19199                Op::Delete => {
19200                    self.delete_base_row(table_name, &change.row_id)
19201                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19202                    if mirror_visible {
19203                        let sql = format!(
19204                            "DELETE FROM {} WHERE {}",
19205                            visible_table(table_name),
19206                            row_id_predicate(table)
19207                        );
19208                        self.conn
19209                            .prepare_cached(&sql)
19210                            .and_then(|mut statement| {
19211                                statement.execute(rusqlite::params![change.row_id])
19212                            })
19213                            .map_err(|error| {
19214                                ("sync.invalid_request".to_owned(), error.to_string())
19215                            })?;
19216                    }
19217                }
19218            }
19219        }
19220        if mirror_visible {
19221            {
19222                let changed_rows: HashSet<(&str, &str)> = changes
19223                    .iter()
19224                    .map(|change| {
19225                        (
19226                            tables[change.table_index as usize].as_str(),
19227                            change.row_id.as_str(),
19228                        )
19229                    })
19230                    .collect();
19231                let acknowledged = self
19232                    .acknowledged_operations(Some(&changed_rows), None)
19233                    .map_err(|error| ("storage.failed".to_owned(), error))?;
19234                self.apply_outbox_ops(&acknowledged)
19235                    .map_err(|message| ("storage.failed".into(), message))?;
19236                self.apply_outbox_ops(
19237                    self.outbox
19238                        .iter()
19239                        .flat_map(|commit| &commit.ops)
19240                        .filter(|op| {
19241                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
19242                        }),
19243                )
19244                .map_err(|message| ("storage.failed".into(), message))?;
19245            }
19246            self.overlay_dirty.set(false);
19247        }
19248        Ok(())
19249    }
19250
19251    /// §5.6 segment application: validate against the generated schema,
19252    /// clear the grant on a fresh bootstrap's first page (fail closed
19253    /// without a mapping), then replace-or-upsert each row with its
19254    /// segment-carried server version (§5.2).
19255    fn apply_segment(
19256        &mut self,
19257        sub_index: usize,
19258        segment: &RowsSegment,
19259        first_fresh_page: bool,
19260    ) -> Result<u32, SectionError> {
19261        let (sub_table, effective) = {
19262            let sub = &self.subs[sub_index];
19263            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19264        };
19265        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19266            SectionError::Abort(
19267                "sync.schema_mismatch".to_owned(),
19268                format!("subscription table {sub_table:?} is not in the client schema"),
19269            )
19270        })?;
19271        // §5.2: the column table validates against the generated schema —
19272        // order, names, types, nullability; mismatch is fatal. §5.11: the
19273        // server sends the WIRE types (bytes for an encrypted column), so
19274        // validate against wire_columns.
19275        let matches = segment.table == table.name
19276            && segment.schema_version == self.schema.version
19277            && segment.columns.len() == table.wire_columns.len()
19278            && segment
19279                .columns
19280                .iter()
19281                .zip(table.wire_columns.iter())
19282                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
19283        if !matches {
19284            return Err(SectionError::Abort(
19285                "sync.schema_mismatch".to_owned(),
19286                "segment column table does not match the generated schema (§5.2)".to_owned(),
19287            ));
19288        }
19289        let mut applied = 0u32;
19290        let empty = Vec::new();
19291        let blocks = if segment.blocks.is_empty() {
19292            std::slice::from_ref(&empty)
19293        } else {
19294            &segment.blocks
19295        };
19296        for (index, block) in blocks.iter().enumerate() {
19297            let clear = first_fresh_page && index == 0;
19298            if !clear && block.is_empty() {
19299                continue;
19300            }
19301            let was_dirty = self.overlay_dirty.snapshot();
19302            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19303                && self
19304                    .outbox
19305                    .iter()
19306                    .flat_map(|commit| &commit.ops)
19307                    .any(|op| op.table == table.name);
19308            let mirror_visible = !reconcile_pending
19309                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19310            self.coalesce_unit(block.len())
19311                .and_then(|()| self.begin_observation("syncular_segment_block"))
19312                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19313            let outcome = (|| {
19314                self.rebuild_overlay_if_dirty()
19315                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19316                let mut changed_keys = Vec::new();
19317                let mut batch = ChangeAccumulator::default();
19318                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
19319                    batch.table(&table.name);
19320                }
19321                if clear {
19322                    self.purge_scope_rows(&table.name, &effective)
19323                        .map_err(|()| SectionError::FailClosed)?;
19324                }
19325                for (row_index, row) in block.iter().enumerate() {
19326                    let decrypted;
19327                    let values = if table.has_encrypted_columns() {
19328                        let mut values = row.values.clone();
19329                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
19330                            .map_err(|message| {
19331                            SectionError::Abort("client.decrypt_failed".into(), message)
19332                        })?;
19333                        decrypted = values;
19334                        &decrypted
19335                    } else {
19336                        &row.values
19337                    };
19338                    self.write_base_row(&table.name, values, row.server_version)
19339                        .map_err(|message| {
19340                            SectionError::Abort("sync.invalid_request".into(), message)
19341                        })?;
19342                    if reconcile_pending {
19343                        changed_keys.push(
19344                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
19345                                |message| SectionError::Abort("storage.failed".into(), message),
19346                            )?,
19347                        );
19348                    }
19349                    if mirror_visible {
19350                        self.write_row(
19351                            &visible_table(&table.name),
19352                            &table.name,
19353                            values,
19354                            row.server_version,
19355                        )
19356                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19357                    }
19358                    let processed = u64::from(applied) + row_index as u64 + 1;
19359                    if processed.is_multiple_of(1024) {
19360                        self.progress.update(|p| p.rows_processed = processed);
19361                    }
19362                }
19363                if reconcile_pending {
19364                    self.reconcile_imported_rows(&table, &mut changed_keys)
19365                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19366                }
19367                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19368                    self.apply_outbox_ops(
19369                        self.outbox
19370                            .iter()
19371                            .flat_map(|commit| &commit.ops)
19372                            .filter(|op| op.table == table.name),
19373                    )
19374                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19375                    self.overlay_dirty.set(false);
19376                } else {
19377                    self.rebuild_overlay_if_dirty()
19378                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19379                }
19380                self.finish_observation("syncular_segment_block", batch)
19381                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
19382            })();
19383            if let Err(error) = outcome {
19384                self.rollback_observation("syncular_segment_block");
19385                self.overlay_dirty.restore(was_dirty.clone());
19386                return Err(error);
19387            }
19388            applied += block.len() as u32;
19389            self.progress
19390                .update(|p| p.rows_processed = u64::from(applied));
19391            std::thread::yield_now();
19392        }
19393        self.progress
19394            .update(|p| p.rows_processed = u64::from(applied));
19395        Ok(applied)
19396    }
19397
19398    /// §5.3 sqlite-image application: validate the in-file metadata
19399    /// against the descriptor, validate column names/order against the
19400    /// generated schema, run the §5.6 first-page clear when fresh, then
19401    /// replace-or-upsert every image row with its `_syncular_version`.
19402    /// Mechanics: the image lands in a temp file read through a second
19403    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
19404    /// Each image chunk commits its writes and revision before readers continue.
19405    fn apply_sqlite_segment(
19406        &mut self,
19407        sub_index: usize,
19408        bytes: &[u8],
19409        first_fresh_page: bool,
19410        row_count: i64,
19411        as_of_commit_seq: i64,
19412        scope_digest: &str,
19413    ) -> Result<u32, SectionError> {
19414        let invalid = |detail: &str| {
19415            SectionError::Abort(
19416                "sync.invalid_request".to_owned(),
19417                format!("sqlite segment rejected: {detail} (§5.3)"),
19418            )
19419        };
19420        let (sub_table, effective) = {
19421            let sub = &self.subs[sub_index];
19422            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19423        };
19424        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19425            SectionError::Abort(
19426                "sync.schema_mismatch".to_owned(),
19427                format!("subscription table {sub_table:?} is not in the client schema"),
19428            )
19429        })?;
19430
19431        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
19432        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
19433        let img = match rusqlite::Connection::open_with_flags(
19434            &path,
19435            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
19436        ) {
19437            Ok(conn) => conn,
19438            Err(_) => {
19439                let _ = std::fs::remove_file(&path);
19440                return Err(invalid("bytes do not open as a SQLite database"));
19441            }
19442        };
19443        let outcome = self.apply_sqlite_image(
19444            &img,
19445            &table,
19446            first_fresh_page,
19447            &effective,
19448            row_count,
19449            as_of_commit_seq,
19450            scope_digest,
19451        );
19452        drop(img);
19453        let _ = std::fs::remove_file(&path);
19454        outcome
19455    }
19456
19457    #[allow(clippy::too_many_arguments)]
19458    fn apply_sqlite_image(
19459        &mut self,
19460        img: &rusqlite::Connection,
19461        table: &crate::schema::TableSchema,
19462        first_fresh_page: bool,
19463        effective: &[(String, Vec<String>)],
19464        row_count: i64,
19465        as_of_commit_seq: i64,
19466        scope_digest: &str,
19467    ) -> Result<u32, SectionError> {
19468        let invalid = |detail: String| {
19469            SectionError::Abort(
19470                "sync.invalid_request".to_owned(),
19471                format!("sqlite segment rejected: {detail} (§5.3)"),
19472            )
19473        };
19474
19475        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
19476        //    one row).
19477        type MetaRow = (i64, String, i64, i64, String, i64, i64);
19478        let meta: MetaRow = img
19479            .query_row(
19480                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
19481                        \"scopeDigest\", \"rowCount\",
19482                        (SELECT count(*) FROM _syncular_segment)
19483                 FROM _syncular_segment",
19484                [],
19485                |row| {
19486                    Ok((
19487                        row.get(0)?,
19488                        row.get(1)?,
19489                        row.get(2)?,
19490                        row.get(3)?,
19491                        row.get(4)?,
19492                        row.get(5)?,
19493                        row.get(6)?,
19494                    ))
19495                },
19496            )
19497            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
19498        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
19499        if meta_count != 1 {
19500            return Err(invalid(format!(
19501                "_syncular_segment must contain exactly one row, found {meta_count}"
19502            )));
19503        }
19504        if format != 1 {
19505            return Err(invalid(format!("format {format}")));
19506        }
19507        if meta_table != table.name {
19508            return Err(invalid(format!("image table {meta_table:?}")));
19509        }
19510        if schema_version != i64::from(self.schema.version) {
19511            return Err(invalid(format!("schemaVersion {schema_version}")));
19512        }
19513        if pin != as_of_commit_seq {
19514            return Err(invalid(format!("asOfCommitSeq {pin}")));
19515        }
19516        if digest != scope_digest {
19517            return Err(invalid("scopeDigest mismatch".to_owned()));
19518        }
19519        if meta_rows != row_count {
19520            return Err(invalid(format!("rowCount {meta_rows}")));
19521        }
19522
19523        // 2. Column names and order vs the generated schema (§5.3 rule 3).
19524        let mut names: Vec<String> = Vec::new();
19525        let mut primary_keys: Vec<String> = Vec::new();
19526        {
19527            let mut stmt = img
19528                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
19529                .map_err(|_| invalid("image data table missing".to_owned()))?;
19530            let mut rows = stmt
19531                .query([])
19532                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
19533            while let Some(row) = rows
19534                .next()
19535                .map_err(|_| invalid("image data table unreadable".to_owned()))?
19536            {
19537                if row
19538                    .get::<_, i64>(5)
19539                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
19540                    > 0
19541                {
19542                    primary_keys.push(row.get(1).map_err(|error| {
19543                        invalid(Self::sqlite_failure(&self.storage_failure, error))
19544                    })?);
19545                }
19546                names.push(
19547                    row.get::<_, String>(1)
19548                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
19549                );
19550            }
19551        }
19552        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
19553        expected.push("_syncular_version");
19554        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
19555            return Err(SectionError::Abort(
19556                "sync.schema_mismatch".to_owned(),
19557                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
19558            ));
19559        }
19560
19561        if primary_keys != vec![table.primary_key.clone()] {
19562            return Err(invalid(
19563                "image primary key does not match generated schema".into(),
19564            ));
19565        }
19566        let actual_count: i64 = img
19567            .query_row(
19568                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
19569                [],
19570                |row| row.get(0),
19571            )
19572            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19573        if actual_count != row_count {
19574            return Err(invalid("image row count does not match descriptor".into()));
19575        }
19576        let insert = self.insert_row_sql(&base_table(&table.name), table);
19577        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
19578        let column_list = names
19579            .iter()
19580            .map(|n| quote_ident(n))
19581            .collect::<Vec<_>>()
19582            .join(", ");
19583        let mut stmt = img
19584            .prepare(&format!(
19585                "SELECT {column_list} FROM {} ORDER BY {}",
19586                quote_ident(&table.name),
19587                quote_ident(&table.primary_key)
19588            ))
19589            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19590        let mut rows = stmt
19591            .query([])
19592            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19593        let mut applied = 0u32;
19594        loop {
19595            let clear = applied == 0 && first_fresh_page;
19596            let was_dirty = self.overlay_dirty.snapshot();
19597            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19598                && self
19599                    .outbox
19600                    .iter()
19601                    .flat_map(|commit| &commit.ops)
19602                    .any(|op| op.table == table.name);
19603            let mirror_visible = !reconcile_pending
19604                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19605            // A chunk holds up to 1,024 rows (§5.3).
19606            self.coalesce_unit(1024)
19607                .and_then(|()| self.begin_observation("syncular_image_chunk"))
19608                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19609            let result = (|| {
19610                self.rebuild_overlay_if_dirty()
19611                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19612                let mut changed_keys = Vec::new();
19613                let mut batch = ChangeAccumulator::default();
19614                if clear {
19615                    if self.scoped_rows_exist(&table.name, effective) {
19616                        batch.table(&table.name);
19617                    }
19618                    self.purge_scope_rows(&table.name, effective)
19619                        .map_err(|()| SectionError::FailClosed)?;
19620                }
19621                let mut processed = 0u32;
19622                {
19623                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
19624                        invalid(Self::sqlite_failure(&self.storage_failure, error))
19625                    })?;
19626                    let mut mirror = if mirror_visible {
19627                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
19628                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19629                        })?)
19630                    } else {
19631                        None
19632                    };
19633                    for _ in 0..1024 {
19634                        let Some(row) = rows.next().map_err(|error| {
19635                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19636                        })?
19637                        else {
19638                            break;
19639                        };
19640                        for (i, column) in table.columns.iter().enumerate() {
19641                            let cell = row.get_ref(i).map_err(|error| {
19642                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19643                            })?;
19644                            let param = image_cell_param(column, cell).map_err(invalid)?;
19645                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
19646                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19647                            })?;
19648                            if let Some(mirror) = &mut mirror {
19649                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
19650                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
19651                                })?;
19652                            }
19653                        }
19654                        let version_index = table.columns.len();
19655                        let version: i64 = row.get(version_index).map_err(|error| {
19656                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19657                        })?;
19658                        if version < 1 {
19659                            return Err(invalid("image row version must be positive".into()));
19660                        }
19661                        ins.raw_bind_parameter(version_index + 1, version)
19662                            .map_err(|error| {
19663                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19664                            })?;
19665                        ins.raw_execute().map_err(|error| {
19666                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19667                        })?;
19668                        if let Some(mirror) = &mut mirror {
19669                            mirror
19670                                .raw_bind_parameter(version_index + 1, version)
19671                                .map_err(|error| {
19672                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
19673                                })?;
19674                            mirror.raw_execute().map_err(|error| {
19675                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19676                            })?;
19677                        }
19678                        if reconcile_pending {
19679                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
19680                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
19681                            )?);
19682                        }
19683                        processed += 1;
19684                    }
19685                }
19686                if processed > 0 {
19687                    batch.table(&table.name);
19688                    self.overlay_dirty.table(&table.name);
19689                }
19690                if reconcile_pending {
19691                    self.reconcile_imported_rows(table, &mut changed_keys)
19692                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19693                }
19694                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19695                    self.apply_outbox_ops(
19696                        self.outbox
19697                            .iter()
19698                            .flat_map(|commit| &commit.ops)
19699                            .filter(|op| op.table == table.name),
19700                    )
19701                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19702                    self.overlay_dirty.set(false);
19703                } else {
19704                    self.rebuild_overlay_if_dirty()
19705                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19706                }
19707                self.finish_observation("syncular_image_chunk", batch)
19708                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19709                Ok(processed)
19710            })();
19711            let processed = match result {
19712                Ok(processed) => processed,
19713                Err(error) => {
19714                    self.rollback_observation("syncular_image_chunk");
19715                    self.overlay_dirty.restore(was_dirty.clone());
19716                    return Err(error);
19717                }
19718            };
19719            applied += processed;
19720            self.progress
19721                .update(|p| p.rows_processed = u64::from(applied));
19722            std::thread::yield_now();
19723            if i64::from(applied) == actual_count {
19724                break;
19725            }
19726        }
19727        Ok(applied)
19728    }
19729
19730    /// Restore only imported and pending row identities before FIFO replay. All pending
19731    /// identities participate: a server row can free a unique value for a different edit.
19732    /// The caller owns the chunk transaction and publishes its revision after replay.
19733    fn reconcile_imported_rows(
19734        &self,
19735        table: &TableSchema,
19736        keys: &mut Vec<SqlValue>,
19737    ) -> Result<(), String> {
19738        let visible = visible_table(&table.name);
19739        let base = base_table(&table.name);
19740        let pk = quote_ident(&table.primary_key);
19741        let predicate = row_id_predicate(table);
19742        let mut lookup = self.conn.prepare_cached(&format!(
19743            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
19744        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19745        let mut deleted = HashSet::new();
19746        for op in self
19747            .outbox
19748            .iter()
19749            .flat_map(|commit| &commit.ops)
19750            .filter(|op| op.table == table.name)
19751        {
19752            if op.upsert {
19753                if let Some(values) = &op.values {
19754                    let key = json_to_column_value(
19755                        &table.columns[table.pk_index],
19756                        values.get(&table.primary_key),
19757                    )?;
19758                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
19759                }
19760            } else if deleted.insert(&op.row_id) {
19761                keys.extend(
19762                    lookup
19763                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
19764                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19765                        .collect::<Result<Vec<_>, _>>()
19766                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19767                );
19768            }
19769        }
19770        // Delete the complete affected set before restoring any base value, so
19771        // old optimistic unique values cannot collide with the new server rows.
19772        for chunk in keys.chunks(400) {
19773            let holes = vec!["?"; chunk.len()].join(",");
19774            self.conn
19775                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
19776                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19777                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19778        }
19779        let updates = table
19780            .columns
19781            .iter()
19782            .map(|column| column.name.as_str())
19783            .chain(std::iter::once("_syncular_version"))
19784            .filter(|name| *name != table.primary_key)
19785            .map(|name| {
19786                let name = quote_ident(name);
19787                format!("{name}=excluded.{name}")
19788            })
19789            .collect::<Vec<_>>()
19790            .join(",");
19791        for chunk in keys.chunks(400) {
19792            let holes = vec!["?"; chunk.len()].join(",");
19793            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
19794                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19795                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19796        }
19797        Ok(())
19798    }
19799
19800    // -- application-authorized local purge ---------------------------------------
19801
19802    fn compile_local_data_purge(
19803        &self,
19804        input: &LocalDataPurgeInput,
19805    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
19806        let invalid = |message: String| format!("sync.invalid_request: {message}");
19807        if input.purge_id.is_empty()
19808            || input.purge_id.len() > 128
19809            || !is_local_operation_code_like(&input.purge_id)
19810        {
19811            return Err(invalid(
19812                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
19813            ));
19814        }
19815        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
19816            return Err(invalid(format!(
19817                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
19818            )));
19819        }
19820
19821        let mut deduplicated: BTreeMap<
19822            String,
19823            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
19824        > = BTreeMap::new();
19825        for target in &input.targets {
19826            let table = self.schema.table(&target.table).ok_or_else(|| {
19827                invalid(format!(
19828                    "local purge names unknown table {:?}",
19829                    target.table
19830                ))
19831            })?;
19832            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
19833                return Err(invalid(format!(
19834                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
19835                    target.table
19836                )));
19837            }
19838            let mut selectors = Vec::with_capacity(target.selectors.len());
19839            let mut canonical_selectors = BTreeMap::new();
19840            for (column_name, raw_values) in &target.selectors {
19841                let Some((column_index, column)) = table
19842                    .columns
19843                    .iter()
19844                    .enumerate()
19845                    .find(|(_, column)| column.name == *column_name)
19846                else {
19847                    return Err(invalid(format!(
19848                        "local purge target {:?} names unknown column {:?}",
19849                        target.table, column_name
19850                    )));
19851                };
19852                let encrypted = table
19853                    .encrypted_columns
19854                    .iter()
19855                    .any(|candidate| candidate.index == column_index);
19856                if column.ty != ColumnType::String || encrypted {
19857                    return Err(invalid(format!(
19858                        "local purge selector {:?}.{:?} must be a plaintext string column",
19859                        target.table, column_name
19860                    )));
19861                }
19862                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
19863                    return Err(invalid(format!(
19864                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
19865                        target.table, column_name
19866                    )));
19867                }
19868                let mut values = raw_values.clone();
19869                values.sort();
19870                values.dedup();
19871                if values.iter().any(|value| {
19872                    value.is_empty()
19873                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
19874                        || !is_local_operation_code_like(value)
19875                }) {
19876                    return Err(invalid(format!(
19877                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
19878                    )));
19879                }
19880                selectors.push((column_name.clone(), values.clone()));
19881                canonical_selectors.insert(column_name.clone(), values);
19882            }
19883            let canonical = LocalDataPurgeTarget {
19884                table: target.table.clone(),
19885                selectors: canonical_selectors,
19886            };
19887            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19888            deduplicated.insert(
19889                key,
19890                (
19891                    CompiledLocalDataPurgeTarget {
19892                        table: target.table.clone(),
19893                        selectors,
19894                    },
19895                    canonical,
19896                ),
19897            );
19898        }
19899        let targets = deduplicated
19900            .values()
19901            .map(|(compiled, _)| compiled.clone())
19902            .collect::<Vec<_>>();
19903        let canonical = deduplicated
19904            .values()
19905            .map(|(_, target)| target.clone())
19906            .collect::<Vec<_>>();
19907        let canonical_plan =
19908            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19909        Ok((targets, canonical_plan))
19910    }
19911
19912    fn local_purge_base_row_ids(
19913        &self,
19914        targets: &[CompiledLocalDataPurgeTarget],
19915    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
19916        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
19917        for target in targets {
19918            let table = self
19919                .schema
19920                .table(&target.table)
19921                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
19922            let mut clauses = Vec::with_capacity(target.selectors.len());
19923            let mut params = Vec::new();
19924            for (column, values) in &target.selectors {
19925                clauses.push(format!(
19926                    "{} IN ({})",
19927                    quote_ident(column),
19928                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
19929                ));
19930                params.extend(values.iter().cloned().map(SqlValue::Text));
19931            }
19932            let sql = format!(
19933                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
19934                quote_ident(&table.primary_key),
19935                base_table(&target.table),
19936                clauses.join(" AND ")
19937            );
19938            let mut statement = self
19939                .conn
19940                .prepare(&sql)
19941                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19942            let rows = statement
19943                .query_map(rusqlite::params_from_iter(params), |row| {
19944                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
19945                    render_row_id_json(Some(&value))
19946                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
19947                })
19948                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19949            let ids = by_table.entry(target.table.clone()).or_default();
19950            for row in rows {
19951                ids.insert(
19952                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19953                );
19954            }
19955        }
19956        Ok(by_table)
19957    }
19958
19959    fn local_purge_values_match(
19960        target: &CompiledLocalDataPurgeTarget,
19961        values: &Map<String, Value>,
19962    ) -> bool {
19963        target.selectors.iter().all(|(column, allowed)| {
19964            values
19965                .get(column)
19966                .and_then(Value::as_str)
19967                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
19968        })
19969    }
19970
19971    /// Apply one host-authorized local security purge. The host MUST gate the
19972    /// corresponding subscriptions first; this primitive owns local SQLite
19973    /// cleanup and never grants/revokes server authority by itself.
19974    pub fn purge_local_data(
19975        &mut self,
19976        input: &LocalDataPurgeInput,
19977    ) -> Result<LocalDataPurgeResult, String> {
19978        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
19979        let meta_key = format!("localPurge:{}", input.purge_id);
19980        let applied_plan = self
19981            .conn
19982            .query_row(
19983                "SELECT value FROM _syncular_meta WHERE key = ?1",
19984                rusqlite::params![meta_key],
19985                |row| row.get::<_, String>(0),
19986            )
19987            .optional()
19988            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19989        if let Some(applied_plan) = applied_plan {
19990            if applied_plan != canonical_plan {
19991                return Err(format!(
19992                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
19993                    input.purge_id
19994                ));
19995            }
19996            return Ok(LocalDataPurgeResult {
19997                already_applied: true,
19998                purged_rows: 0,
19999                dropped_commits: 0,
20000            });
20001        }
20002
20003        // RFC 0005 D7: the container file cannot be a purge target (its name is
20004        // not in the running schema), so purge drops it as a fixed step, before
20005        // any mirror row is touched and unconditionally.
20006        let _ = self.drop_previous_version();
20007
20008        let prior_outbox = self.outbox.clone();
20009        let prior_failed = self.failed_commits.clone();
20010        let prior_rejections = self.rejections.clone();
20011        let prior_conflicts = self.conflicts.clone();
20012        let prior_overlay_dirty = self.overlay_dirty.snapshot();
20013        self.begin_observation("syncular_local_purge")?;
20014        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
20015            let row_ids = self.local_purge_base_row_ids(&targets)?;
20016            let retained_dropped = self.drop_failed_commits(|table, values| {
20017                targets.iter().any(|target| {
20018                    target.table == table && Self::local_purge_values_match(target, values)
20019                })
20020            })?;
20021            let doomed = self
20022                .outbox
20023                .iter()
20024                .filter(|commit| {
20025                    commit.ops.iter().any(|operation| {
20026                        let matching_targets = targets
20027                            .iter()
20028                            .filter(|target| target.table == operation.table)
20029                            .collect::<Vec<_>>();
20030                        if matching_targets.is_empty() {
20031                            return false;
20032                        }
20033                        if row_ids
20034                            .get(&operation.table)
20035                            .is_some_and(|ids| ids.contains(&operation.row_id))
20036                        {
20037                            return true;
20038                        }
20039                        operation.values.as_ref().is_some_and(|values| {
20040                            matching_targets
20041                                .iter()
20042                                .any(|target| Self::local_purge_values_match(target, values))
20043                        })
20044                    })
20045                })
20046                .cloned()
20047                .collect::<Vec<_>>();
20048            let doomed_ids = doomed
20049                .iter()
20050                .map(|commit| commit.client_commit_id.clone())
20051                .collect::<BTreeSet<_>>();
20052            let mut batch = ChangeAccumulator::default();
20053            for commit in &retained_dropped {
20054                batch.conflicts = true;
20055                batch.rejections = true;
20056                batch.outcomes = true;
20057                for operation in &commit.ops {
20058                    batch.table(&operation.table);
20059                }
20060            }
20061            let mut rejections = Vec::new();
20062            for commit in &doomed {
20063                for operation in &commit.ops {
20064                    batch.table(&operation.table);
20065                }
20066                let results = commit
20067                    .ops
20068                    .iter()
20069                    .enumerate()
20070                    .map(|(op_index, operation)| {
20071                        let rejection = RejectionRecord {
20072                            client_commit_id: commit.client_commit_id.clone(),
20073                            op_index: op_index as i32,
20074                            code: "client.local_data_purged".to_owned(),
20075                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
20076                            retryable: false,
20077                            details: None,
20078                            operation: Some(CommitOperation::from(operation)),
20079                        };
20080                        rejections.push(rejection.clone());
20081                        CommitOperationOutcome::Error { rejection }
20082                    })
20083                    .collect::<Vec<_>>();
20084                self.persist_commit_outcome(
20085                    &commit.client_commit_id,
20086                    CommitOutcomeStatus::Rejected,
20087                    &results,
20088                    Some(&commit.ops),
20089                )?;
20090                self.delete_outbox_persisted(&commit.client_commit_id)?;
20091            }
20092            if !doomed.is_empty() {
20093                self.outbox
20094                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
20095                self.rejections.extend(rejections);
20096                self.prune_commit_outcomes()?;
20097                batch.status = true;
20098                batch.rejections = true;
20099                batch.outcomes = true;
20100            }
20101
20102            let mut purged_rows = 0usize;
20103            for (table, ids) in &row_ids {
20104                if ids.is_empty() {
20105                    continue;
20106                }
20107                batch.table(table);
20108                for row_id in ids {
20109                    self.delete_base_row(table, row_id)?;
20110                    purged_rows += 1;
20111                }
20112            }
20113            self.rebuild_overlay_if_dirty()?;
20114            self.delete_unreferenced_cached_blobs()?;
20115            self.conn
20116                .execute(
20117                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20118                    rusqlite::params![meta_key, canonical_plan],
20119                )
20120                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20121            Ok((
20122                batch,
20123                LocalDataPurgeResult {
20124                    already_applied: false,
20125                    purged_rows,
20126                    dropped_commits: doomed.len(),
20127                },
20128            ))
20129        })();
20130        let (batch, result) = match applied {
20131            Ok(value) => value,
20132            Err(error) => {
20133                self.rollback_observation("syncular_local_purge");
20134                self.outbox = prior_outbox;
20135                self.failed_commits = prior_failed;
20136                self.rejections = prior_rejections;
20137                self.conflicts = prior_conflicts;
20138                self.overlay_dirty.restore(prior_overlay_dirty.clone());
20139                return Err(error);
20140            }
20141        };
20142        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
20143            self.rollback_observation("syncular_local_purge");
20144            self.outbox = prior_outbox;
20145            self.failed_commits = prior_failed;
20146            self.rejections = prior_rejections;
20147            self.conflicts = prior_conflicts;
20148            self.overlay_dirty.restore(prior_overlay_dirty.clone());
20149            return Err(error);
20150        }
20151        self.cancel_sync_round();
20152        Ok(result)
20153    }
20154
20155    /// Application-authorized recovery of the replicated projection. Unlike a
20156    /// local security purge, this retains the entire outbox, device identity,
20157    /// lease, outcomes, subscription registrations, and protected bookkeeping.
20158    /// The projection reset, subscription rewind, optimistic replay, and
20159    /// idempotency marker share one savepoint for interruption safety.
20160    pub fn rebootstrap_local_data(
20161        &mut self,
20162        input: &LocalDataRebootstrapInput,
20163    ) -> Result<LocalDataRebootstrapResult, String> {
20164        if input.rebootstrap_id.is_empty()
20165            || input.rebootstrap_id.len() > 128
20166            || !is_local_operation_code_like(&input.rebootstrap_id)
20167        {
20168            return Err(
20169                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
20170                    .to_owned(),
20171            );
20172        }
20173        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
20174        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
20175            let (retained_commits, reset_subscriptions) =
20176                decode_local_rebootstrap_receipt(&persisted)?;
20177            return Ok(LocalDataRebootstrapResult {
20178                already_applied: true,
20179                retained_commits,
20180                reset_subscriptions,
20181            });
20182        }
20183        if self.stopped || self.schema_floor.is_some() {
20184            return Err(
20185                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
20186                    .to_owned(),
20187            );
20188        }
20189
20190        let retained_commits = self.outbox.len();
20191        let reset_subscriptions = self.subs.len();
20192        let prior_subs = self.subs.clone();
20193        let prior_upgrading = self.upgrading;
20194        let prior_stopped = self.stopped;
20195        let prior_schema_floor = self.schema_floor.clone();
20196        let prior_overlay_dirty = self.overlay_dirty.snapshot();
20197        let prior_sync_needed = self.sync_needed;
20198        let prior_sync_intents = self.sync_intent_queue.clone();
20199        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
20200
20201        let prior_active_round = self.active_round;
20202        self.begin_observation("syncular_local_rebootstrap")?;
20203        let mut batch = ChangeAccumulator::default();
20204        let applied = (|| -> Result<(), String> {
20205            self.run_schema_reset_observed(&mut batch, false)?;
20206            self.conn
20207                .execute(
20208                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20209                    rusqlite::params![meta_key, receipt],
20210                )
20211                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20212            self.sync_needed = true;
20213            self.sync_intent_queue.push_back(SyncIntent::Interactive);
20214            batch.status = true;
20215            Ok(())
20216        })();
20217        if let Err(error) = applied {
20218            self.rollback_observation("syncular_local_rebootstrap");
20219            self.active_round = prior_active_round;
20220            self.subs = prior_subs;
20221            self.upgrading = prior_upgrading;
20222            self.stopped = prior_stopped;
20223            self.schema_floor = prior_schema_floor;
20224            self.overlay_dirty.restore(prior_overlay_dirty.clone());
20225            self.sync_needed = prior_sync_needed;
20226            self.sync_intent_queue = prior_sync_intents;
20227            return Err(error);
20228        }
20229        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
20230            self.rollback_observation("syncular_local_rebootstrap");
20231            self.active_round = prior_active_round;
20232            self.subs = prior_subs;
20233            self.upgrading = prior_upgrading;
20234            self.stopped = prior_stopped;
20235            self.schema_floor = prior_schema_floor;
20236            self.overlay_dirty.restore(prior_overlay_dirty.clone());
20237            self.sync_needed = prior_sync_needed;
20238            self.sync_intent_queue = prior_sync_intents;
20239            return Err(error);
20240        }
20241
20242        Ok(LocalDataRebootstrapResult {
20243            already_applied: false,
20244            retained_commits,
20245            reset_subscriptions,
20246        })
20247    }
20248
20249    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
20250
20251    /// Delete base rows matching the effective scopes; `Err(())` = no local
20252    /// scope-column mapping for a key (the fail-closed case).
20253    fn purge_scope_rows(
20254        &mut self,
20255        table_name: &str,
20256        effective: &[(String, Vec<String>)],
20257    ) -> Result<(), ()> {
20258        if effective.is_empty() {
20259            return Ok(());
20260        }
20261        let table = self.schema.table(table_name).ok_or(())?.clone();
20262        let mut clauses = Vec::new();
20263        let mut params: Vec<SqlValue> = Vec::new();
20264        for (variable, values) in effective {
20265            let column = table.scope_column(variable).ok_or(())?;
20266            let placeholders: Vec<String> = values
20267                .iter()
20268                .map(|v| {
20269                    params.push(SqlValue::Text(v.clone()));
20270                    "?".to_owned()
20271                })
20272                .collect();
20273            clauses.push(format!(
20274                "{} IN ({})",
20275                quote_ident(column),
20276                placeholders.join(", ")
20277            ));
20278        }
20279        let sql = format!(
20280            "DELETE FROM {} WHERE {}",
20281            base_table(table_name),
20282            clauses.join(" AND ")
20283        );
20284        self.overlay_dirty.table(table_name);
20285        self.conn
20286            .execute(&sql, rusqlite::params_from_iter(&params))
20287            .map_err(|_| ())?;
20288        self.conn
20289            .execute(
20290                &format!(
20291                    "DELETE FROM {} WHERE {}",
20292                    visible_table(table_name),
20293                    clauses.join(" AND ")
20294                ),
20295                rusqlite::params_from_iter(&params),
20296            )
20297            .map_err(|_| ())?;
20298        Ok(())
20299    }
20300
20301    /// §3.3: drop pending commits whose upserts provably land in the
20302    /// revoked effective scopes — whole-commit, never per-operation.
20303    fn drop_doomed_outbox(
20304        &mut self,
20305        table_name: &str,
20306        effective: &[(String, Vec<String>)],
20307    ) -> Result<bool, String> {
20308        if effective.is_empty() {
20309            return Ok(false);
20310        }
20311        let Some(table) = self.schema.table(table_name).cloned() else {
20312            return Ok(false);
20313        };
20314        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
20315        for (variable, values) in effective {
20316            match table.scope_column(variable) {
20317                Some(column) => mappings.push((column, values)),
20318                None => return Ok(false), // not provable without a mapping
20319            }
20320        }
20321        let doomed: Vec<OutboxCommit> = self
20322            .outbox
20323            .iter()
20324            .filter(|commit| {
20325                commit.ops.iter().any(|op| {
20326                    op.upsert
20327                        && op.table == table_name
20328                        && op.values.as_ref().is_some_and(|values| {
20329                            mappings
20330                                .iter()
20331                                .all(|(column, allowed)| match values.get(*column) {
20332                                    Some(Value::String(s)) => allowed.contains(s),
20333                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
20334                                    Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
20335                                    _ => false,
20336                                })
20337                        })
20338                })
20339            })
20340            .cloned()
20341            .collect();
20342        if doomed.is_empty() {
20343            return Ok(false);
20344        }
20345        let mut rejections = Vec::new();
20346        for commit in &doomed {
20347            let results = commit
20348                .ops
20349                .iter()
20350                .enumerate()
20351                .map(|(op_index, operation)| {
20352                    let rejection = RejectionRecord {
20353                        client_commit_id: commit.client_commit_id.clone(),
20354                        op_index: op_index as i32,
20355                        code: "sync.scope_revoked".to_owned(),
20356                        message: "the commit was dropped because its effective scope was revoked"
20357                            .to_owned(),
20358                        retryable: false,
20359                        details: None,
20360                        operation: Some(CommitOperation::from(operation)),
20361                    };
20362                    rejections.push(rejection.clone());
20363                    CommitOperationOutcome::Error { rejection }
20364                })
20365                .collect::<Vec<_>>();
20366            self.persist_commit_outcome(
20367                &commit.client_commit_id,
20368                CommitOutcomeStatus::Rejected,
20369                &results,
20370                Some(&commit.ops),
20371            )?;
20372            self.delete_outbox_persisted(&commit.client_commit_id)?;
20373        }
20374        self.prune_commit_outcomes()?;
20375        let doomed_ids = doomed
20376            .iter()
20377            .map(|commit| commit.client_commit_id.as_str())
20378            .collect::<BTreeSet<_>>();
20379        self.outbox
20380            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
20381        self.rejections.extend(rejections);
20382        Ok(true)
20383    }
20384
20385    // -- blobs (§5.9) ----------------------------------------------------------------
20386
20387    /// §5.9.7: hash bytes into the content address, cache them, queue the
20388    /// upload (flushed before the next push, B4). Returns the canonical
20389    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
20390    /// column value.
20391    pub fn upload_blob(
20392        &mut self,
20393        bytes: &[u8],
20394        media_type: Option<String>,
20395        name: Option<String>,
20396    ) -> Result<Value, String> {
20397        let blob_id = blob_id_for(bytes);
20398        let now = self.clock_now_ms();
20399        let tx = self
20400            .conn
20401            .transaction()
20402            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20403        tx.execute(
20404            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
20405             ON CONFLICT(blob_id) DO NOTHING",
20406            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
20407        )
20408        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20409        tx.execute(
20410            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
20411            rusqlite::params![blob_id, media_type, now],
20412        )
20413        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20414        tx.commit()
20415            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20416        self.enforce_blob_cache_cap();
20417        let mut obj = Map::new();
20418        obj.insert("blobId".to_owned(), Value::from(blob_id));
20419        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
20420        if let Some(mt) = media_type {
20421            obj.insert("mediaType".to_owned(), Value::from(mt));
20422        }
20423        if let Some(n) = name {
20424            obj.insert("name".to_owned(), Value::from(n));
20425        }
20426        Ok(Value::Object(obj))
20427    }
20428
20429    /// §5.9.7: resolve an owned blob body without driver encoding. A
20430    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
20431    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
20432    pub fn fetch_blob_bytes(
20433        &mut self,
20434        transport: &mut dyn Transport,
20435        blob_id_or_ref: &str,
20436    ) -> Result<FetchedBlob, (String, String)> {
20437        let simple = |m: String| ("client.failed".to_owned(), m);
20438        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
20439            blob_id_or_ref.to_owned()
20440        } else {
20441            let value: Value = serde_json::from_str(blob_id_or_ref)
20442                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
20443            value
20444                .get("blobId")
20445                .and_then(Value::as_str)
20446                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
20447                .to_owned()
20448        };
20449        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
20450            return Ok(cached);
20451        }
20452        if !self.transport_enabled {
20453            return Err(("sync.offline".into(), "transport gate is closed".into()));
20454        }
20455        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
20456        // blob.not_found) verbatim so the harness can assert on it. The
20457        // authorized endpoint serves bytes inline OR (always-issue, presign
20458        // configured) a signed url the client fetches directly — no host auth,
20459        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
20460        #[cfg(feature = "bench-internals")]
20461        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
20462        let bytes = match transport
20463            .blob_download(&blob_id)
20464            .map_err(|e| (e.code, e.message))?
20465        {
20466            BlobDownload::Bytes(bytes) => bytes,
20467            BlobDownload::Url {
20468                url,
20469                url_expires_at_ms,
20470            } => {
20471                // §5.9.5: MUST NOT start a fetch at/past expiry.
20472                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
20473                    return Err((
20474                        "sync.segment_expired".to_owned(),
20475                        format!(
20476                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
20477                        ),
20478                    ));
20479                }
20480                transport
20481                    .fetch_blob_url(&url)
20482                    .map_err(|e| (e.code, e.message))?
20483            }
20484        };
20485        #[cfg(feature = "bench-internals")]
20486        drop(download_phase);
20487        #[cfg(feature = "bench-internals")]
20488        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
20489        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
20490        if blob_id_for(&bytes) != blob_id {
20491            return Err(simple(format!(
20492                "blob content address mismatch for {blob_id}"
20493            )));
20494        }
20495        #[cfg(feature = "bench-internals")]
20496        drop(validate_phase);
20497        #[cfg(feature = "bench-internals")]
20498        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
20499        let now = self.clock_now_ms();
20500        self.conn
20501            .execute(
20502                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
20503                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
20504            )
20505            .map_err(|e| simple(e.to_string()))?;
20506        self.conn
20507            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
20508            .map_err(|e| simple(e.to_string()))?;
20509        self.enforce_blob_cache_cap();
20510        #[cfg(feature = "bench-internals")]
20511        drop(insert_phase);
20512        self.get_cached_blob_bytes(&blob_id)
20513            .map_err(simple)?
20514            .ok_or_else(|| simple("blob cache write failed".to_owned()))
20515    }
20516
20517    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
20518        #[cfg(feature = "bench-internals")]
20519        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
20520        let mut stmt = self
20521            .conn
20522            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
20523            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20524        let mut rows = stmt
20525            .query(rusqlite::params![blob_id])
20526            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20527        if let Some(row) = rows
20528            .next()
20529            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20530        {
20531            let bytes: Vec<u8> = row
20532                .get(0)
20533                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20534            let byte_length: i64 = row
20535                .get(1)
20536                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20537            let media_type: Option<String> = row
20538                .get(2)
20539                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20540            return Ok(Some(FetchedBlob {
20541                blob_id: blob_id.to_owned(),
20542                byte_length,
20543                bytes,
20544                media_type,
20545            }));
20546        }
20547        Ok(None)
20548    }
20549
20550    /// §5.9.7 B4: upload every queued blob before push.
20551    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
20552        let pending: Vec<(String, Option<String>)> = {
20553            let mut stmt = self
20554                .conn
20555                .prepare(
20556                    "SELECT blob_id, media_type FROM (
20557                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
20558                      UNION ALL
20559                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
20560                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
20561                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
20562                    ) ORDER BY created_at_ms, blob_id",
20563                )
20564                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20565            let rows = stmt
20566                .query_map([], |row| {
20567                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
20568                })
20569                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20570            rows.collect::<Result<_, _>>()
20571                .map_err(|error| match error {
20572                    rusqlite::Error::InvalidColumnType(..)
20573                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
20574                        "sync.local_corrupt",
20575                        "Pending blob upload metadata is invalid",
20576                    ),
20577                    _ => TransportError::new("client.failed", error.to_string()),
20578                })?
20579        };
20580        let mut uploads = Vec::new();
20581        for (blob_id, media_type) in pending {
20582            let bytes = (|| {
20583                let stored: Option<(SqlValue, SqlValue)> = self
20584                    .conn
20585                    .query_row(
20586                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
20587                        rusqlite::params![blob_id],
20588                        |row| Ok((row.get(0)?, row.get(1)?)),
20589                    )
20590                    .optional()
20591                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20592                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
20593                    return Err(TransportError::new(
20594                        "sync.local_corrupt",
20595                        "Pending blob upload body is missing or corrupt",
20596                    ));
20597                };
20598                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
20599                    return Err(TransportError::new(
20600                        "sync.local_corrupt",
20601                        "Pending blob upload body is missing or corrupt",
20602                    ));
20603                }
20604                Ok(bytes)
20605            })();
20606            let failed = bytes.is_err();
20607            uploads.push(crate::round::PendingUpload {
20608                id: blob_id,
20609                bytes,
20610                media_type,
20611            });
20612            if failed {
20613                break;
20614            }
20615        }
20616        Ok(uploads)
20617    }
20618
20619    fn visible_blob_ids_sql(&self) -> String {
20620        let mut selects = Vec::new();
20621        for table in &self.schema.tables {
20622            for column in table
20623                .columns
20624                .iter()
20625                .filter(|column| column.ty == ColumnType::BlobRef)
20626            {
20627                let identifier = quote_ident(&column.name);
20628                selects.push(format!(
20629                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
20630                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
20631                     AND json_type({identifier}, '$.blobId') = 'text'",
20632                    quote_ident(&table.name),
20633                ));
20634            }
20635        }
20636        if selects.is_empty() {
20637            "SELECT NULL AS blob_id WHERE 0".to_owned()
20638        } else {
20639            selects.join(" UNION ")
20640        }
20641    }
20642
20643    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
20644    /// staged uploads pin bodies directly; no derived refcount is stored.
20645    fn enforce_blob_cache_cap(&self) {
20646        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
20647            return;
20648        };
20649        let mut total: i64 = self
20650            .conn
20651            .query_row(
20652                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
20653                [],
20654                |row| row.get(0),
20655            )
20656            .unwrap_or(0);
20657        if total <= max_bytes {
20658            return;
20659        }
20660        let query = format!(
20661            "SELECT blob_id, byte_length FROM _syncular_blobs
20662             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20663               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20664               AND blob_id NOT IN ({})
20665             ORDER BY created_at_ms ASC, blob_id ASC",
20666            self.visible_blob_ids_sql(),
20667        );
20668        let candidates: Vec<(String, i64)> = {
20669            let Ok(mut stmt) = self.conn.prepare(&query) else {
20670                return;
20671            };
20672            let Ok(rows) = stmt.query_map([], |row| {
20673                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
20674            }) else {
20675                return;
20676            };
20677            rows.filter_map(Result::ok).collect()
20678        };
20679        for (blob_id, byte_length) in candidates {
20680            if total <= max_bytes {
20681                break;
20682            }
20683            let _ = self.conn.execute(
20684                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
20685                rusqlite::params![blob_id],
20686            );
20687            total -= byte_length;
20688        }
20689    }
20690
20691    fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
20692        #[cfg(feature = "bench-internals")]
20693        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
20694        if !self.schema_has_blobs() {
20695            return Ok(());
20696        }
20697        self.rebuild_overlay_if_dirty()?;
20698        self.conn
20699            .execute(
20700                &format!(
20701                    "DELETE FROM _syncular_blobs
20702                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20703                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20704                   AND blob_id NOT IN ({})",
20705                    self.visible_blob_ids_sql(),
20706                ),
20707                [],
20708            )
20709            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20710        Ok(())
20711    }
20712
20713    // -- local row storage ----------------------------------------------------------
20714
20715    /// The cached per-table primary-key upsert SQL for `full_table` (see
20716    /// the `insert_sql` field: built once, reused per row).
20717    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
20718        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
20719            return sql.clone();
20720        }
20721        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
20722        columns.push(quote_ident("_syncular_version"));
20723        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
20724        let primary_key = quote_ident(&table.primary_key);
20725        let updates = columns
20726            .iter()
20727            .filter(|column| **column != primary_key)
20728            .map(|column| format!("{column}=excluded.{column}"))
20729            .collect::<Vec<_>>()
20730            .join(", ");
20731        let sql = format!(
20732            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
20733            columns.join(", "),
20734            placeholders.join(", ")
20735        );
20736        self.insert_sql
20737            .borrow_mut()
20738            .insert(full_table.to_owned(), sql.clone());
20739        sql
20740    }
20741
20742    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
20743        self.overlay_dirty.table(table_name);
20744        self.write_row(&base_table(table_name), table_name, row, version)
20745    }
20746
20747    fn write_row(
20748        &self,
20749        full_table: &str,
20750        table_name: &str,
20751        row: &Row,
20752        version: i64,
20753    ) -> Result<(), String> {
20754        #[cfg(feature = "bench-internals")]
20755        let _phase = self.benchmark_phases.start(Phase::RowWrite);
20756        let table = self
20757            .schema
20758            .table(table_name)
20759            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20760        let sql = self.insert_row_sql(full_table, table);
20761        let mut stmt = self
20762            .conn
20763            .prepare_cached(&sql)
20764            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20765        let params = row
20766            .iter()
20767            .map(RowParam::Cell)
20768            .chain(std::iter::once(RowParam::Version(version)));
20769        stmt.execute(rusqlite::params_from_iter(params))
20770            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20771        Ok(())
20772    }
20773
20774    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
20775        let table = self
20776            .schema
20777            .table(table_name)
20778            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20779        self.overlay_dirty.table(table_name);
20780        let sql = format!(
20781            "DELETE FROM {} WHERE {}",
20782            base_table(table_name),
20783            row_id_predicate(table)
20784        );
20785        let mut stmt = self
20786            .conn
20787            .prepare_cached(&sql)
20788            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20789        stmt.execute(rusqlite::params![row_id])
20790            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20791        Ok(())
20792    }
20793
20794    fn has_acknowledged_rows(&self) -> bool {
20795        self.conn
20796            .query_row(
20797                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
20798                [],
20799                |row| row.get(0),
20800            )
20801            .expect("read acknowledged intent")
20802    }
20803
20804    fn acknowledged_operations(
20805        &self,
20806        rows: Option<&HashSet<(&str, &str)>>,
20807        tables: Option<&BTreeSet<&str>>,
20808    ) -> Result<Vec<OutboxOp>, String> {
20809        let keys = rows.map(|rows| {
20810            serde_json::to_string(
20811                &rows
20812                    .iter()
20813                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
20814                    .collect::<Vec<_>>(),
20815            )
20816            .expect("row keys")
20817        });
20818        let mut statement = self
20819            .conn
20820            .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")
20821            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20822        let rows = statement
20823            .query_map(
20824                rusqlite::params![
20825                    keys,
20826                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
20827                ],
20828                |row| {
20829                    #[cfg(test)]
20830                    self.acknowledged_replay_count
20831                        .set(self.acknowledged_replay_count.get() + 1);
20832                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
20833                },
20834            )
20835            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20836        rows.map(|row| {
20837            let (op, intent) =
20838                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20839            let op: CommitOperation =
20840                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
20841            let table = self
20842                .schema
20843                .table(&op.table)
20844                .ok_or_else(|| "sync.unknown_table".to_owned())?;
20845            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
20846                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
20847            } else {
20848                op.values
20849            };
20850            Ok(OutboxOp {
20851                table: op.table,
20852                row_id: op.row_id,
20853                upsert: op.op == "upsert",
20854                base_version: op.base_version,
20855                values,
20856            })
20857        })
20858        .collect()
20859    }
20860
20861    fn drop_acknowledged_rows(
20862        &self,
20863        matches: impl Fn(&str, &Map<String, Value>) -> bool,
20864    ) -> Result<Vec<OutboxCommit>, String> {
20865        let mut statement = self
20866            .conn
20867            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
20868            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20869        let rows = statement
20870            .query_map([], |row| {
20871                Ok((
20872                    row.get::<_, String>(0)?,
20873                    row.get::<_, String>(1)?,
20874                    row.get::<_, String>(2)?,
20875                    row.get::<_, String>(3)?,
20876                ))
20877            })
20878            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20879        let mut doomed = HashSet::new();
20880        for row in rows {
20881            let (id, table, intent, row_id) =
20882                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20883            let values =
20884                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
20885            let base_matches = self
20886                .schema
20887                .table(&table)
20888                .map(|schema| self.stored_row(schema, &row_id, true))
20889                .transpose()?
20890                .flatten()
20891                .is_some_and(|(base, _)| {
20892                    let schema = self.schema.table(&table).expect("acknowledged table");
20893                    let values = schema
20894                        .columns
20895                        .iter()
20896                        .zip(base.iter())
20897                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
20898                        .collect();
20899                    matches(&table, &values)
20900                });
20901            if matches(&table, &values) || base_matches {
20902                doomed.insert(id);
20903            }
20904        }
20905        let mut dropped = Vec::new();
20906        for id in doomed {
20907            let mut statement = self
20908                .conn
20909                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
20910                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20911            let operations = statement
20912                .query_map([&id], |row| row.get::<_, String>(0))
20913                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20914            let mut ops = Vec::new();
20915            for operation in operations {
20916                let json = operation
20917                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20918                let op: CommitOperation =
20919                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
20920                self.overlay_dirty.table(&op.table);
20921                ops.push(OutboxOp {
20922                    table: op.table,
20923                    row_id: op.row_id,
20924                    upsert: op.op == "upsert",
20925                    base_version: op.base_version,
20926                    values: op.values,
20927                });
20928            }
20929            dropped.push(OutboxCommit {
20930                client_commit_id: id.clone(),
20931                ops,
20932            });
20933            self.conn
20934                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
20935                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20936        }
20937        Ok(dropped)
20938    }
20939
20940    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
20941    /// the outbox changed since the last rebuild (the no-op sync round).
20942    fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
20943        if self.overlay_dirty.get() {
20944            self.rebuild_overlay()?;
20945        }
20946        Ok(())
20947    }
20948
20949    /// §7.1: local reads see outbox state applied optimistically — rebuild
20950    /// every visible table as (base server state) + (pending outbox replay
20951    /// on top). Optimistic rows carry version `-1`.
20952    fn rebuild_overlay(&mut self) -> Result<(), String> {
20953        #[cfg(feature = "bench-internals")]
20954        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
20955        #[cfg(test)]
20956        self.overlay_rebuild_count
20957            .set(self.overlay_rebuild_count.get() + 1);
20958        let dirty = self.overlay_dirty.snapshot();
20959        let tables: Vec<_> = self
20960            .schema
20961            .tables
20962            .iter()
20963            .filter(|table| {
20964                dirty
20965                    .as_ref()
20966                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
20967            })
20968            .cloned()
20969            .collect();
20970        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
20971        self.begin_observation("syncular_overlay")?;
20972        let rebuilt = (|| {
20973            for table in &tables {
20974                for index in &table.fts_indexes {
20975                    self.drop_fts_triggers(index)?;
20976                }
20977                let visible = visible_table(&table.name);
20978                let base = base_table(&table.name);
20979                self.conn
20980                    .execute_batch(&format!(
20981                        "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
20982                    ))
20983                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20984            }
20985            let acknowledged = self.acknowledged_operations(None, Some(&names))?;
20986            self.apply_outbox_ops(
20987                acknowledged
20988                    .iter()
20989                    .filter(|op| names.contains(op.table.as_str())),
20990            )?;
20991            for (commit, initial) in &self.failed_commits {
20992                for operation in &commit.ops {
20993                    if !names.contains(operation.table.as_str()) || !operation.upsert {
20994                        continue;
20995                    }
20996                    if let Some(table) = self.schema.table(&operation.table) {
20997                        if self.visible_row(table, &operation.row_id)?.is_none()
20998                            && operation.values.as_ref().is_some_and(|values| {
20999                                table
21000                                    .columns
21001                                    .iter()
21002                                    .all(|column| values.contains_key(&column.name))
21003                            })
21004                        {
21005                            let key = serde_json::to_string(&(
21006                                operation.table.clone(),
21007                                operation.row_id.clone(),
21008                            ))
21009                            .expect("row key serialization");
21010                            if let Some(Value::Object(values)) = initial.get(&key) {
21011                                self.apply_outbox_ops(&[OutboxOp {
21012                                    values: Some(values.clone()),
21013                                    ..operation.clone()
21014                                }])?;
21015                            }
21016                        }
21017                    }
21018                }
21019                self.apply_outbox_ops(
21020                    commit
21021                        .ops
21022                        .iter()
21023                        .filter(|op| names.contains(op.table.as_str())),
21024                )?;
21025            }
21026            for commit in &self.outbox {
21027                self.apply_outbox_ops(
21028                    commit
21029                        .ops
21030                        .iter()
21031                        .filter(|op| names.contains(op.table.as_str())),
21032                )?;
21033            }
21034            for table in &tables {
21035                for index in &table.fts_indexes {
21036                    self.rebuild_fts_projection(table, index)?;
21037                    self.create_fts_triggers(table, index)?;
21038                }
21039            }
21040            self.conn
21041                .execute_batch("RELEASE syncular_overlay")
21042                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21043            Ok(())
21044        })();
21045        if rebuilt.is_err() {
21046            self.conn
21047                .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
21048                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21049        } else {
21050            self.overlay_dirty.set(false);
21051        }
21052        rebuilt
21053    }
21054
21055    /// §7.1 replay: every operation is applied over the current visible
21056    /// state. An operation that no longer applies (a server row now holds its
21057    /// unique value) stays pending and invisible until a later replay admits
21058    /// it or the server answers its push.
21059    fn apply_outbox_ops<'a>(
21060        &self,
21061        ops: impl IntoIterator<Item = &'a OutboxOp>,
21062    ) -> Result<(), String> {
21063        #[cfg(feature = "bench-internals")]
21064        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
21065        for op in ops {
21066            #[cfg(feature = "bench-internals")]
21067            if let Some(phase) = &mut phase {
21068                phase.unit();
21069            }
21070            if op.upsert {
21071                if let (Some(table), Some(values)) = (
21072                    self.schema
21073                        .table(&op.table)
21074                        .filter(|table| table.indexes.iter().any(|index| index.unique)),
21075                    op.values.as_ref(),
21076                ) {
21077                    let mut intended: Map<String, Value> = self
21078                        .visible_row(table, &op.row_id)?
21079                        .map(|(row, _)| {
21080                            table
21081                                .columns
21082                                .iter()
21083                                .zip(&row)
21084                                .map(|(column, value)| {
21085                                    (column.name.clone(), column_value_to_json(value))
21086                                })
21087                                .collect()
21088                        })
21089                        .unwrap_or_default();
21090                    intended.extend(values.clone());
21091                    if !self.unique_conflicts(table, &intended, false)?.is_empty() {
21092                        continue;
21093                    }
21094                }
21095            }
21096            self.apply_outbox_op(op)?;
21097        }
21098        Ok(())
21099    }
21100
21101    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
21102        let Some(table) = self.schema.table(&op.table) else {
21103            return Ok(());
21104        };
21105        let visible = visible_table(&table.name);
21106        if !op.upsert {
21107            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
21108            self.conn
21109                .prepare_cached(&sql)
21110                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
21111                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21112            return Ok(());
21113        }
21114        let Some(values) = op.values.as_ref() else {
21115            return Ok(());
21116        };
21117        // §7.1: the overlay applies the operation's PRESENT columns
21118        // over the current local row. A partial operation over an
21119        // absent local row leaves it absent — the server answers it
21120        // with sync.row_deleted or sync.row_missing (§6.2).
21121        let full = table
21122            .columns
21123            .iter()
21124            .all(|column| values.contains_key(&column.name));
21125        let local = self.visible_row(table, &op.row_id)?;
21126        if local.is_none() && !full {
21127            return Ok(());
21128        }
21129        let mut row: Row = Vec::with_capacity(table.columns.len());
21130        for (index, column) in table.columns.iter().enumerate() {
21131            let incoming = match values.get(&column.name) {
21132                Some(raw) => json_to_column_value(
21133                    if raw.get("$bytes").is_some()
21134                        && table
21135                            .encrypted_columns
21136                            .iter()
21137                            .any(|encrypted| encrypted.index == index)
21138                    {
21139                        &table.wire_columns[index]
21140                    } else {
21141                        column
21142                    },
21143                    Some(raw),
21144                ),
21145                None => Ok(local
21146                    .as_ref()
21147                    .and_then(|(values, _)| values.get(index).cloned())
21148                    .flatten()),
21149            };
21150            row.push(incoming.map_err(|_| {
21151                "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
21152            })?);
21153        }
21154        let version = local.as_ref().map_or(-1, |(_, version)| *version);
21155        self.write_row(&visible, &table.name, &row, version)
21156            .map_err(|error| {
21157                if error.starts_with("UNIQUE constraint failed:") {
21158                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
21159                } else {
21160                    error
21161                }
21162            })
21163    }
21164
21165    /// The visible (optimistic) row's values plus version, or `None` when the
21166    /// row is absent from the overlay.
21167    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
21168        self.stored_row(table, row_id, false)
21169    }
21170
21171    fn stored_row(
21172        &self,
21173        table: &TableSchema,
21174        row_id: &str,
21175        base: bool,
21176    ) -> Result<Option<(Row, i64)>, String> {
21177        let sql = format!(
21178            "SELECT * FROM {} WHERE {}",
21179            if base {
21180                base_table(&table.name)
21181            } else {
21182                visible_table(&table.name)
21183            },
21184            row_id_predicate(table)
21185        );
21186        let mut stmt = self
21187            .conn
21188            .prepare_cached(&sql)
21189            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21190        let mut rows = stmt
21191            .query(rusqlite::params![row_id])
21192            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21193        let Some(row) = rows
21194            .next()
21195            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
21196        else {
21197            return Ok(None);
21198        };
21199        let mut values = Vec::with_capacity(table.columns.len());
21200        for (index, column) in table.columns.iter().enumerate() {
21201            let raw = row
21202                .get_ref(index)
21203                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21204            if let rusqlite::types::ValueRef::Blob(bytes) = raw {
21205                if table
21206                    .encrypted_columns
21207                    .iter()
21208                    .any(|encrypted| encrypted.index == index)
21209                {
21210                    values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
21211                    continue;
21212                }
21213            }
21214            let json = sql_ref_to_json(column, raw);
21215            values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
21216                "sync.local_corrupt: persisted local row value is invalid".to_owned()
21217            })?);
21218        }
21219        let version: i64 = row
21220            .get(table.columns.len())
21221            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21222        Ok(Some((values, version)))
21223    }
21224
21225    // -- realtime (§8) ---------------------------------------------------------------
21226
21227    /// §8.8: the configured realtime policy.
21228    #[must_use]
21229    pub fn realtime_policy(&self) -> RealtimePolicy {
21230        self.realtime_policy
21231    }
21232
21233    /// §8.8: the explicit realtime availability state.
21234    #[must_use]
21235    pub fn realtime_state(&self) -> RealtimeState {
21236        self.realtime_state
21237    }
21238
21239    /// §8.8: set the realtime policy. Refused while the socket is connected:
21240    /// a policy change never silently re-routes an ownership state.
21241    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
21242        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
21243            return Err(
21244                "sync.invalid_request: disconnect realtime before changing the realtime policy"
21245                    .to_owned(),
21246            );
21247        }
21248        self.realtime_policy = policy;
21249        match policy {
21250            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
21251            RealtimePolicy::Required | RealtimePolicy::Optional
21252                if self.realtime_state == RealtimeState::Disabled =>
21253            {
21254                self.set_realtime_state(RealtimeState::Disconnected, None);
21255            }
21256            RealtimePolicy::Required | RealtimePolicy::Optional => {}
21257        }
21258        Ok(())
21259    }
21260
21261    /// Record an availability transition, keeping the reason code bounded and
21262    /// code-like (§7.6) and clearing it where it cannot apply.
21263    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
21264        self.realtime_state = state;
21265        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
21266        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
21267            self.realtime_reason_code = None;
21268            self.realtime_retry_delay_ms = None;
21269        }
21270    }
21271
21272    #[must_use]
21273    fn realtime_connected(&self) -> bool {
21274        self.realtime_state == RealtimeState::Connected
21275    }
21276
21277    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
21278        if !self.transport_enabled {
21279            return Err("sync.offline: transport gate is closed".into());
21280        }
21281        if self.realtime_policy == RealtimePolicy::Off {
21282            return Err(
21283                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
21284            );
21285        }
21286        if self.realtime_connected() {
21287            return Ok(());
21288        }
21289        self.realtime_state = RealtimeState::Connecting;
21290        match transport.realtime_connect_for_client(&self.client_id) {
21291            Ok(()) => {
21292                self.set_realtime_state(RealtimeState::Connected, None);
21293                Ok(())
21294            }
21295            Err(error) => {
21296                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
21297                Err(format!("{}: {}", error.code, error.message))
21298            }
21299        }
21300    }
21301
21302    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
21303        self.cancel_sync_round();
21304        // A deliberately disconnected or policy-disabled client has nothing
21305        // to release; a `lost` socket still does (the host owns the handle).
21306        if self.realtime_state == RealtimeState::Disconnected
21307            || self.realtime_state == RealtimeState::Disabled
21308        {
21309            return;
21310        }
21311        let _ = transport.realtime_close();
21312        self.set_realtime_state(
21313            if self.realtime_policy == RealtimePolicy::Off {
21314                RealtimeState::Disabled
21315            } else {
21316                RealtimeState::Disconnected
21317            },
21318            None,
21319        );
21320        self.presence.clear(); // §8.6.1: presence is per-connection
21321    }
21322
21323    /// §8.8: the host observed the socket end without disconnecting it (a
21324    /// peer close, an I/O error, a failed send; the native transport reports
21325    /// these as `Inbound::Disconnected`). Releases the transport, moves a
21326    /// `Connected` state to `Lost` with the transport's code as the reason, and schedules
21327    /// one background retry intent so the host reconnects and catches up.
21328    pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
21329        if self.realtime_state != RealtimeState::Connected {
21330            return;
21331        }
21332        self.cancel_sync_round();
21333        let _ = transport.realtime_close();
21334        self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
21335        self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
21336        self.presence.clear();
21337    }
21338
21339    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
21340    /// document for `scope_key`. Requires a live socket; the document is
21341    /// ephemeral (lost on disconnect). Authorization is the connection's
21342    /// registration (§8.6.3) — an unheld key is rejected loudly by the
21343    /// server with `presence.forbidden`.
21344    pub fn set_presence(
21345        &mut self,
21346        transport: &mut dyn Transport,
21347        scope_key: &str,
21348        doc: Option<&Value>,
21349    ) -> Result<(), String> {
21350        if !self.transport_enabled {
21351            return Err("sync.offline: transport gate is closed".into());
21352        }
21353        if !self.realtime_connected() {
21354            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
21355        }
21356        let text = encode_presence_publish(scope_key, doc);
21357        transport
21358            .realtime_send(&text)
21359            .map_err(|e| format!("{}: {}", e.code, e.message))
21360    }
21361
21362    /// §8.6: the peers currently present on a scope key (ephemeral).
21363    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
21364        self.presence
21365            .get(scope_key)
21366            .map(|peers| peers.values().cloned().collect())
21367            .unwrap_or_default()
21368    }
21369
21370    /// §8.6 apply an inbound presence fanout to the local map.
21371    fn apply_presence(
21372        &mut self,
21373        scope_key: String,
21374        kind: Option<PresenceKind>,
21375        actor_id: Option<String>,
21376        client_id: Option<String>,
21377        doc: Option<Value>,
21378        error: Option<String>,
21379    ) {
21380        // The publisher-directed error variant is out-of-band; nothing to
21381        // record in the peer map.
21382        if error.is_some() {
21383            return;
21384        }
21385        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
21386            return;
21387        };
21388        let peer_key = format!("{actor_id} {client_id}");
21389        match kind {
21390            PresenceKind::Leave => {
21391                if let Some(peers) = self.presence.get_mut(&scope_key) {
21392                    peers.remove(&peer_key);
21393                    if peers.is_empty() {
21394                        self.presence.remove(&scope_key);
21395                    }
21396                }
21397            }
21398            _ => {
21399                let doc = match doc {
21400                    Some(Value::Object(_)) => doc.unwrap(),
21401                    _ => return,
21402                };
21403                self.presence.entry(scope_key).or_default().insert(
21404                    peer_key,
21405                    PresencePeer {
21406                        actor_id,
21407                        client_id,
21408                        doc,
21409                    },
21410                );
21411            }
21412        }
21413    }
21414
21415    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
21416    pub fn on_realtime_text(&mut self, text: &str) {
21417        if self.stopped || !self.transport_enabled || self.security_preflight {
21418            return;
21419        }
21420        match parse_control(text) {
21421            Ok(ControlMessage::Hello { requires_sync, .. }) => {
21422                if requires_sync {
21423                    // §8.1: pull before trusting the socket for continuity.
21424                    self.set_sync_needed(true, true);
21425                }
21426            }
21427            Ok(ControlMessage::Presence {
21428                scope_key,
21429                kind,
21430                actor_id,
21431                client_id,
21432                doc,
21433                error,
21434                ..
21435            }) => {
21436                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
21437            }
21438            Ok(ControlMessage::Wake { .. }) => {
21439                // §8.3: any wake-up means "run a pull soon", never data.
21440                self.set_sync_needed(true, true);
21441            }
21442            _ => {}
21443        }
21444    }
21445
21446    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
21447    /// a pull response per section; an unapplied delta is a wake-up.
21448    /// Mailbox hosts send durable acknowledgements on their I/O executor.
21449    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
21450        let mut transport = crate::round::DownloadResults::default();
21451        self.on_realtime_binary(&mut transport, bytes);
21452        transport.controls
21453    }
21454
21455    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
21456        if self.stopped || !self.transport_enabled || self.security_preflight {
21457            return;
21458        }
21459        #[cfg(feature = "bench-internals")]
21460        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
21461        let message = match decode_message(bytes) {
21462            Ok(m) if m.msg_kind == MsgKind::Response => m,
21463            _ => {
21464                self.set_sync_needed(true, true);
21465                return;
21466            }
21467        };
21468        #[cfg(feature = "bench-internals")]
21469        drop(decode_phase);
21470        let coalesced_deletes =
21471            coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
21472                if status != SubStatus::Active {
21473                    return false;
21474                }
21475                let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
21476                    return false;
21477                };
21478                sub.state == SubState::Active
21479                    && sub.bootstrap_state.is_none()
21480                    && sub.synced_once
21481                    && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
21482            });
21483        let mut frames = message.frames.into_iter();
21484        let mut applied_cursor: Option<i64> = None;
21485        let mut any_covered = false;
21486        let mut dropped = self.begin_coalesced_apply().is_err();
21487        while let Some(frame) = frames.next() {
21488            let Frame::SubStart {
21489                id,
21490                status,
21491                effective_scopes,
21492                ..
21493            } = frame
21494            else {
21495                continue;
21496            };
21497            let mut body = Vec::new();
21498            let mut next_cursor: Option<i64> = None;
21499            for inner in frames.by_ref() {
21500                match inner {
21501                    Frame::SubEnd {
21502                        next_cursor: nc, ..
21503                    } => {
21504                        next_cursor = Some(nc);
21505                        break;
21506                    }
21507                    Frame::Unknown { .. } => {}
21508                    other => body.push(other),
21509                }
21510            }
21511            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
21512                dropped = true;
21513                continue;
21514            };
21515            let sub = &self.subs[sub_index];
21516            // §8.2: only locally active, not mid-bootstrap subscriptions
21517            // apply; skipped sections are repaired by the next pull.
21518            if status != SubStatus::Active
21519                || sub.state != SubState::Active
21520                || sub.bootstrap_state.is_some()
21521                || !sub.synced_once
21522            {
21523                dropped = true;
21524                continue;
21525            }
21526            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
21527                // Idempotent redelivery of an already-covered window.
21528                any_covered = true;
21529                continue;
21530            }
21531            let previous_effective = self.subs[sub_index].effective.clone();
21532            let previous_cursor = self.subs[sub_index].cursor;
21533            self.subs[sub_index].effective = Some(effective_scopes);
21534            let mut failed = false;
21535            for inner in body {
21536                match inner {
21537                    Frame::Commit {
21538                        tables,
21539                        changes,
21540                        commit_seq,
21541                        ..
21542                    } => {
21543                        if self
21544                            .apply_commit_frame_coalesced(
21545                                &tables,
21546                                changes,
21547                                commit_seq,
21548                                &coalesced_deletes,
21549                            )
21550                            .is_err()
21551                        {
21552                            failed = true;
21553                            break;
21554                        }
21555                    }
21556                    _ => {
21557                        failed = true;
21558                        break;
21559                    }
21560                }
21561            }
21562            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
21563                self.apply_sub_end(sub_index, cursor, None)
21564                    .ok()
21565                    .map(|()| cursor)
21566            });
21567            let Some(next_cursor) = committed else {
21568                self.subs[sub_index].effective = previous_effective;
21569                self.subs[sub_index].cursor = previous_cursor;
21570                dropped = true;
21571                continue;
21572            };
21573            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
21574        }
21575        // §1.4 rule 4: the ack follows the COMMIT. A failed COMMIT restored
21576        // the cursors, so the delta counts as a wake-up.
21577        if self.finish_coalesced_apply().is_err() {
21578            applied_cursor = None;
21579            dropped = true;
21580        }
21581        if let Some(cursor) = applied_cursor {
21582            // §8.2 ack point: the highest applied SUB_END.nextCursor.
21583            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21584            let _ = transport.realtime_send(&ack);
21585        } else if !any_covered || dropped {
21586            // §8.2: a delta not applied at all is treated as a wake-up.
21587            self.set_sync_needed(true, true);
21588        }
21589    }
21590
21591    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
21592    /// cursor across active, non-bootstrapping subscriptions that have
21593    /// synced at least once. No such subscription, no ack.
21594    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
21595        if !self.realtime_connected() {
21596            return;
21597        }
21598        let floor = self
21599            .subs
21600            .iter()
21601            .filter(|s| {
21602                s.state == SubState::Active
21603                    && s.bootstrap_state.is_none()
21604                    && s.synced_once
21605                    && s.cursor >= 0
21606            })
21607            .map(|s| s.cursor)
21608            .min();
21609        if let Some(cursor) = floor {
21610            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21611            let _ = transport.realtime_send(&ack);
21612        }
21613    }
21614}
21615
21616#[cfg(test)]
21617mod previous_version_wiring_tests {
21618    use super::*;
21619    use serde_json::{json, Value};
21620
21621    fn previous_version_schema(version: i32) -> Value {
21622        json!({
21623            "version": version,
21624            "tables": [{
21625                "name": "tasks",
21626                "primaryKey": "id",
21627                "columns": [
21628                    { "name": "id", "type": "string", "nullable": false },
21629                    { "name": "note", "type": "string", "nullable": true }
21630                ],
21631                "scopes": []
21632            }]
21633        })
21634    }
21635
21636    fn previous_version_limits() -> crate::api::ClientLimits {
21637        crate::api::ClientLimits {
21638            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
21639                enabled: true,
21640                ..Default::default()
21641            }),
21642            ..Default::default()
21643        }
21644    }
21645
21646    /// The configuration the live client carries, for a direct capture call.
21647    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
21648        previous_version_limits()
21649            .previous_version_context
21650            .expect("configured")
21651    }
21652
21653    /// Capture the container the way a bump would, so a read test starts from a
21654    /// container this client's own state will then decide about.
21655    fn recapture_previous_version(client: &SyncClient) {
21656        let replica_path = client
21657            .previous_version_replica_path()
21658            .expect("file-backed replica");
21659        let outcome = crate::previous_version::capture_previous_version_from_replica(
21660            &client.conn,
21661            &replica_path,
21662            2,
21663            2,
21664            &previous_version_config(),
21665            1,
21666        )
21667        .expect("capture");
21668        assert!(matches!(
21669            outcome,
21670            crate::previous_version::CaptureOutcome::Captured(_)
21671        ));
21672    }
21673
21674    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
21675        Subscription {
21676            id: "wiring-sub".to_owned(),
21677            table: "tasks".to_owned(),
21678            requested: Vec::new(),
21679            params: None,
21680            cursor,
21681            bootstrap_state: bootstrap_state.map(str::to_owned),
21682            state,
21683            reason_code: None,
21684            effective: None,
21685            synced_once: false,
21686        }
21687    }
21688
21689    #[test]
21690    fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
21691        let path =
21692            std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
21693        let path_str = path.to_str().expect("temp path");
21694        let schema = previous_version_schema(3);
21695        let mut older = previous_version_schema(2);
21696        older["tables"][0]["columns"] =
21697            json!([{ "name": "id", "type": "string", "nullable": false }]);
21698        let mut client = SyncClient::open_path(
21699            "downgrade".into(),
21700            &previous_version_schema(1),
21701            previous_version_limits(),
21702            path_str,
21703        )
21704        .expect("fresh");
21705        client
21706            .mutate(vec![Mutation::Upsert {
21707                table: "tasks".into(),
21708                values: Map::from_iter([
21709                    ("id".into(), json!("accepted")),
21710                    ("note".into(), json!("old")),
21711                ]),
21712                base_version: None,
21713            }])
21714            .expect("v1 write");
21715        client
21716            .recreate_with_schema(&schema)
21717            .expect("upgrade captures context");
21718        let commit = client
21719            .mutate(vec![Mutation::Upsert {
21720                table: "tasks".into(),
21721                values: Map::from_iter([
21722                    ("id".into(), json!("queued")),
21723                    ("note".into(), json!("v3-only")),
21724                ]),
21725                base_version: None,
21726            }])
21727            .expect("queued v3 write");
21728        let changes = client.conn.total_changes();
21729        let outbox = client.pending_commit_ids();
21730        assert!(outbox.contains(&commit));
21731        let descriptor = client.get_meta("localSchemaDescriptor");
21732        let container = format!(
21733            "{path_str}{}",
21734            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
21735        );
21736        let context = std::fs::read(&container).expect("captured context");
21737        assert!(client
21738            .recreate_with_schema(&older)
21739            .expect_err("refuse recreate")
21740            .starts_with("client.schema_downgrade:"));
21741        assert_eq!(client.schema.version, 3);
21742        assert_eq!(client.conn.total_changes(), changes);
21743        assert_eq!(client.pending_commit_ids(), outbox);
21744        assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
21745        assert_eq!(
21746            client
21747                .conn
21748                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21749                    row.get::<_, String>(0)
21750                })
21751                .expect("queued row"),
21752            "v3-only"
21753        );
21754        assert_eq!(std::fs::read(&container).expect("context remains"), context);
21755        drop(client);
21756        let before = std::fs::read(&path).expect("replica bytes");
21757        // open_path uses DELETE journaling. The identity open must refuse
21758        // before its normal WAL configuration changes this database.
21759        for entry in 0..3 {
21760            let error = match entry {
21761                0 => SyncClient::open_path(
21762                    "downgrade".into(),
21763                    &older,
21764                    previous_version_limits(),
21765                    path_str,
21766                ),
21767                1 => SyncClient::open_path_with_identity(
21768                    None,
21769                    &older,
21770                    previous_version_limits(),
21771                    path_str,
21772                ),
21773                _ => SyncClient::with_connection(
21774                    "downgrade".into(),
21775                    &older,
21776                    previous_version_limits(),
21777                    Connection::open(&path).expect("connection"),
21778                ),
21779            }
21780            .err()
21781            .expect("refuse open");
21782            assert!(error.starts_with("client.schema_downgrade:"));
21783            let conn = Connection::open(&path).expect("inspect");
21784            assert_eq!(
21785                conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21786                    .expect("journal mode"),
21787                "delete"
21788            );
21789            drop(conn);
21790            assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
21791            assert_eq!(
21792                std::fs::read(&container).expect("context unchanged"),
21793                context
21794            );
21795        }
21796        let reopened = SyncClient::open_path(
21797            "downgrade".into(),
21798            &schema,
21799            previous_version_limits(),
21800            path_str,
21801        )
21802        .expect("compatible reopen");
21803        assert_eq!(reopened.pending_commit_ids(), outbox);
21804        assert_eq!(
21805            reopened
21806                .conn
21807                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21808                    row.get::<_, String>(0)
21809                })
21810                .expect("queued row remains"),
21811            "v3-only"
21812        );
21813        reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
21814        reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21815        drop(reopened);
21816        let legacy = SyncClient::open_path(
21817            "downgrade".into(),
21818            &schema,
21819            previous_version_limits(),
21820            path_str,
21821        )
21822        .expect("legacy no-marker reopen");
21823        assert_eq!(legacy.pending_commit_ids(), outbox);
21824        assert!(!legacy.upgrading());
21825        assert_eq!(
21826            legacy
21827                .conn
21828                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21829                    row.get::<_, String>(0)
21830                })
21831                .expect("legacy row remains"),
21832            "v3-only"
21833        );
21834        drop(legacy);
21835        std::fs::remove_file(path).expect("remove replica");
21836        let _ = std::fs::remove_file(container);
21837    }
21838
21839    #[test]
21840    fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
21841        for marker in [
21842            "''",
21843            "'bad'",
21844            "'0'",
21845            "'-1'",
21846            "'2.5'",
21847            "'03'",
21848            "'3\n'",
21849            "' 3'",
21850            "'+3'",
21851            "'3e0'",
21852            "'2147483648'",
21853            "NULL",
21854            "X'33'",
21855        ] {
21856            let conn = Connection::open_in_memory().expect("connection");
21857            conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
21858            let error = SyncClient::with_connection(
21859                "marker".into(),
21860                &previous_version_schema(3),
21861                ClientLimits::default(),
21862                conn,
21863            )
21864            .err()
21865            .expect("refuse marker");
21866            assert!(
21867                error.starts_with("sync.local_corrupt:"),
21868                "{marker}: {error}"
21869            );
21870        }
21871        for fault in [
21872            "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
21873            "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
21874        ] {
21875            let mut client = SyncClient::new(
21876                "marker".into(),
21877                &previous_version_schema(3),
21878                ClientLimits::default(),
21879            )
21880            .expect("fresh");
21881            let commit = client
21882                .mutate(vec![Mutation::Upsert {
21883                    table: "tasks".into(),
21884                    values: Map::from_iter([("id".into(), json!("queued"))]),
21885                    base_version: None,
21886                }])
21887                .expect("queued write");
21888            client.conn.execute_batch(fault).expect("marker fault");
21889            let changes = client.conn.total_changes();
21890            let error = client
21891                .recreate_with_schema(&previous_version_schema(4))
21892                .expect_err("refuse corrupt marker");
21893            assert!(error.starts_with("sync.local_corrupt:"));
21894            assert_eq!(client.conn.total_changes(), changes);
21895            assert_eq!(client.schema.version, 3);
21896            assert_eq!(client.pending_commit_ids(), vec![commit]);
21897        }
21898        for setup in [
21899            "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
21900            "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
21901        ] {
21902            let path =
21903                std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
21904            let conn = Connection::open(&path).expect("connection");
21905            conn.execute_batch(setup)
21906                .expect("unreadable metadata fixture");
21907            drop(conn);
21908            let before = std::fs::read(&path).expect("before");
21909            let error = SyncClient::open_path_with_identity(
21910                None,
21911                &previous_version_schema(3),
21912                ClientLimits::default(),
21913                path.to_str().expect("path"),
21914            )
21915            .err()
21916            .expect("refuse unreadable marker");
21917            assert!(error.starts_with("sync.local_corrupt:"));
21918            assert_eq!(std::fs::read(&path).expect("after"), before);
21919            std::fs::remove_file(path).expect("remove fixture");
21920        }
21921    }
21922
21923    std::thread_local! {
21924        /// The pending concurrent upgrade a busy handler commits, so a lock
21925        /// collision deterministically becomes the marker race under test.
21926        static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
21927            const { RefCell::new(None) };
21928    }
21929
21930    fn commit_concurrent_upgrade(attempts: i32) -> bool {
21931        CONCURRENT_UPGRADE.with(|slot| {
21932            if let Some(banner) = slot.borrow_mut().take() {
21933                banner
21934                    .execute_batch(
21935                        "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
21936                    )
21937                    .expect("commit the concurrent upgrade");
21938            }
21939        });
21940        attempts < 10
21941    }
21942
21943    fn journal_mode(conn: &Connection) -> String {
21944        conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21945            .expect("journal mode")
21946    }
21947
21948    fn temp_replica(label: &str) -> std::path::PathBuf {
21949        std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
21950    }
21951
21952    #[test]
21953    fn file_identity_constructor_requires_the_returned_wal_mode() {
21954        for path in [":memory:", ""] {
21955            let error = SyncClient::open_path_with_identity(
21956                None,
21957                &previous_version_schema(1),
21958                ClientLimits::default(),
21959                path,
21960            )
21961            .err()
21962            .expect("temporary SQLite databases cannot enter WAL mode");
21963            assert_eq!(
21964                error,
21965                "sync.invalid_request: file-backed client requires WAL journal mode"
21966            );
21967        }
21968        let path = temp_replica("confirmed-wal");
21969        let client = SyncClient::open_path_with_identity(
21970            None,
21971            &previous_version_schema(1),
21972            ClientLimits::default(),
21973            path.to_str().unwrap(),
21974        )
21975        .unwrap();
21976        assert_eq!(journal_mode(&client.conn), "wal");
21977        drop(client);
21978        std::fs::remove_file(path).unwrap();
21979    }
21980
21981    #[test]
21982    fn non_wal_identity_open_is_accepted_when_wal_is_not_required() {
21983        for path in [":memory:", ""] {
21984            let client = SyncClient::open_path_with_identity_journal(
21985                None,
21986                &previous_version_schema(1),
21987                ClientLimits::default(),
21988                path,
21989                false,
21990            )
21991            .expect("a host without WAL can still open the identity constructor");
21992            assert_ne!(journal_mode(&client.conn), "wal");
21993            assert_eq!(
21994                client
21995                    .conn
21996                    .query_row("PRAGMA synchronous", [], |row| row.get::<_, i64>(0))
21997                    .expect("synchronous"),
21998                2,
21999                "every identity open pins FULL durability"
22000            );
22001        }
22002    }
22003
22004    #[test]
22005    fn reset_replica_deletes_the_database_and_its_sidecars_through_the_vfs() {
22006        let path = temp_replica("reset");
22007        let path_str = path.to_str().expect("path");
22008        let client = SyncClient::open_path(
22009            "reset-replica".into(),
22010            &previous_version_schema(1),
22011            ClientLimits::default(),
22012            path_str,
22013        )
22014        .expect("replica");
22015        let original_id = client.client_id().to_owned();
22016        drop(client);
22017        for suffix in ["-journal", "-wal", "-shm"] {
22018            std::fs::write(format!("{path_str}{suffix}"), b"stale sidecar").expect("sidecar");
22019        }
22020
22021        SyncClient::reset_replica(path_str).expect("reset through the VFS");
22022
22023        for suffix in ["", "-journal", "-wal", "-shm"] {
22024            assert!(
22025                !std::path::Path::new(&format!("{path_str}{suffix}")).exists(),
22026                "reset removes the replica file {suffix:?}"
22027            );
22028        }
22029        let reopened = SyncClient::open_path_with_identity(
22030            None,
22031            &previous_version_schema(1),
22032            ClientLimits::default(),
22033            path_str,
22034        )
22035        .expect("fresh replica");
22036        assert_ne!(reopened.client_id(), original_id);
22037        assert!(reopened
22038            .query("SELECT id FROM tasks", &[])
22039            .expect("empty")
22040            .is_empty());
22041        drop(reopened);
22042        assert_eq!(
22043            SyncClient::reset_replica(":memory:").expect_err("memory has nothing to reset"),
22044            "sync.invalid_request: a memory replica has nothing to reset"
22045        );
22046    }
22047
22048    #[test]
22049    fn non_positive_generated_schema_versions_are_refused_before_storage() {
22050        for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
22051            let mut schema = previous_version_schema(1);
22052            schema["version"] = version.clone();
22053            let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
22054                .err()
22055                .expect("refuse an out-of-range generated version");
22056            assert_eq!(
22057                error,
22058                "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
22059            );
22060            // The file-backed seam refuses before the replica gains a table.
22061            let path = temp_replica("bad-version");
22062            let path_str = path.to_str().expect("path");
22063            for identity in [false, true] {
22064                let error = if identity {
22065                    SyncClient::open_path_with_identity(
22066                        None,
22067                        &schema,
22068                        ClientLimits::default(),
22069                        path_str,
22070                    )
22071                } else {
22072                    SyncClient::open_path(
22073                        "bad-version".into(),
22074                        &schema,
22075                        ClientLimits::default(),
22076                        path_str,
22077                    )
22078                }
22079                .err()
22080                .expect("refuse before storage");
22081                assert!(
22082                    error.starts_with("sync.invalid_request:"),
22083                    "{version}: {error}"
22084                );
22085                assert!(
22086                    !path.exists(),
22087                    "{version}: a refused constructor creates no replica file"
22088                );
22089            }
22090        }
22091        // A missing version is the same request error, not a parse failure.
22092        let mut schema = previous_version_schema(1);
22093        schema
22094            .as_object_mut()
22095            .expect("schema object")
22096            .remove("version");
22097        let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
22098            .err()
22099            .expect("refuse a missing version");
22100        assert!(error.starts_with("sync.invalid_request:"), "{error}");
22101    }
22102
22103    #[test]
22104    fn a_duplicated_schema_marker_is_refused_without_a_reset() {
22105        let path = temp_replica("duplicate-marker");
22106        let path_str = path.to_str().expect("path");
22107        {
22108            // A hand-built/legacy metadata table without its primary key, with
22109            // the older marker first: `query_row` would pick it and reset.
22110            let conn = Connection::open(path_str).expect("fixture");
22111            conn.execute_batch(
22112                "CREATE TABLE _syncular_meta(key TEXT, value);
22113                 INSERT INTO _syncular_meta VALUES
22114                   ('localSchemaVersion', '1'),
22115                   ('localSchemaVersion', '2'),
22116                   ('clientId', 'duplicate');",
22117            )
22118            .expect("duplicate marker fixture");
22119        }
22120        let error = SyncClient::open_path_with_identity(
22121            None,
22122            &previous_version_schema(3),
22123            ClientLimits::default(),
22124            path_str,
22125        )
22126        .err()
22127        .expect("refuse a duplicated marker");
22128        assert!(error.starts_with("sync.local_corrupt:"), "{error}");
22129        let probe = Connection::open(path_str).expect("probe");
22130        assert_eq!(
22131            probe
22132                .query_row(
22133                    "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
22134                    [],
22135                    |row| row.get::<_, i64>(0),
22136                )
22137                .expect("marker rows"),
22138            2,
22139            "the duplicated rows are left for the operator, not collapsed"
22140        );
22141        assert_eq!(
22142            probe
22143                .query_row(
22144                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
22145                    [],
22146                    |row| row.get::<_, i64>(0),
22147                )
22148                .expect("synced tables"),
22149            0,
22150            "a duplicated marker never drives the reset that recreates v3 tables"
22151        );
22152        assert_eq!(
22153            journal_mode(&probe),
22154            "delete",
22155            "the refusal leaves the journal mode alone"
22156        );
22157        drop(probe);
22158        std::fs::remove_file(path).expect("remove replica");
22159    }
22160
22161    #[test]
22162    fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
22163        let path = temp_replica("refused-wal");
22164        let path_str = path.to_str().expect("path");
22165        // `open_path` leaves the default journal mode, so a refused open is
22166        // the only thing that could switch this replica to WAL.
22167        drop(
22168            SyncClient::open_path(
22169                "owner".into(),
22170                &previous_version_schema(2),
22171                ClientLimits::default(),
22172                path_str,
22173            )
22174            .expect("v2 replica"),
22175        );
22176        let before = std::fs::read(&path).expect("before");
22177        let error = SyncClient::open_path_with_identity(
22178            Some("intruder".into()),
22179            &previous_version_schema(2),
22180            ClientLimits::default(),
22181            path_str,
22182        )
22183        .err()
22184        .expect("refuse the identity mismatch");
22185        assert!(error.starts_with("client.identity_mismatch:"), "{error}");
22186        let probe = Connection::open(path_str).expect("probe");
22187        assert_eq!(
22188            journal_mode(&probe),
22189            "delete",
22190            "a refused open must not persist WAL"
22191        );
22192        assert_eq!(std::fs::read(&path).expect("after"), before);
22193        assert_eq!(
22194            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22195            Some("2")
22196        );
22197        drop(probe);
22198        std::fs::remove_file(path).expect("remove replica");
22199    }
22200
22201    #[test]
22202    fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
22203        let path = temp_replica("marker-race");
22204        let path_str = path.to_str().expect("path");
22205        drop(
22206            SyncClient::open_path_with_identity(
22207                Some("stale".into()),
22208                &previous_version_schema(1),
22209                ClientLimits::default(),
22210                path_str,
22211            )
22212            .expect("v1 replica"),
22213        );
22214        // Another process holds the write lock with an uncommitted upgrade; a
22215        // stale open reads the v1 marker first and only then meets the lock.
22216        // The first busy therefore lands after this replica became v2.
22217        let banner = Connection::open(path_str).expect("banner");
22218        banner
22219            .execute_batch(
22220                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22221            )
22222            .expect("pending upgrade");
22223        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22224        let conn = Connection::open(path_str).expect("stale connection");
22225        conn.busy_handler(Some(commit_concurrent_upgrade))
22226            .expect("busy handler");
22227        let error = SyncClient::with_connection(
22228            "stale".into(),
22229            &previous_version_schema(1),
22230            ClientLimits::default(),
22231            conn,
22232        )
22233        .err()
22234        .expect("a concurrent upgrade must refuse the stale open");
22235        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22236        let probe = Connection::open(path_str).expect("probe");
22237        assert_eq!(
22238            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22239            Some("2"),
22240            "the concurrent upgrade survives"
22241        );
22242        drop(probe);
22243        std::fs::remove_file(path).expect("remove replica");
22244    }
22245
22246    #[test]
22247    fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
22248        let path = temp_replica("recreate-race");
22249        let path_str = path.to_str().expect("path");
22250        let mut client = SyncClient::open_path_with_identity(
22251            Some("stale".into()),
22252            &previous_version_schema(1),
22253            ClientLimits::default(),
22254            path_str,
22255        )
22256        .expect("v1 replica");
22257        client
22258            .mutate(vec![Mutation::Upsert {
22259                table: "tasks".into(),
22260                values: Map::from_iter([("id".into(), json!("queued"))]),
22261                base_version: None,
22262            }])
22263            .expect("queued write");
22264        let commit = client.pending_commit_ids();
22265        let banner = Connection::open(path_str).expect("banner");
22266        banner
22267            .execute_batch(
22268                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22269            )
22270            .expect("pending upgrade");
22271        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22272        client
22273            .conn
22274            .busy_handler(Some(commit_concurrent_upgrade))
22275            .expect("busy handler");
22276        let error = client
22277            .recreate_with_schema(&previous_version_schema(1))
22278            .expect_err("a concurrent upgrade must refuse the stale recreate");
22279        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22280        assert_eq!(client.schema.version, 1, "the compiled schema is restored");
22281        assert_eq!(
22282            client.pending_commit_ids(),
22283            commit,
22284            "the outbox is preserved"
22285        );
22286        let probe = Connection::open(path_str).expect("probe");
22287        assert_eq!(
22288            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22289            Some("2")
22290        );
22291        drop(probe);
22292        drop(client);
22293        std::fs::remove_file(path).expect("remove replica");
22294    }
22295
22296    #[test]
22297    fn failed_schema_record_writes_roll_back_recreation() {
22298        for key in [
22299            LOCAL_SCHEMA_VERSION_KEY,
22300            crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
22301        ] {
22302            let mut client = SyncClient::new(
22303                "schema-record".into(),
22304                &previous_version_schema(1),
22305                ClientLimits::default(),
22306            )
22307            .expect("v1");
22308            let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
22309            let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
22310            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");
22311            client
22312                .recreate_with_schema(&previous_version_schema(2))
22313                .expect_err("both record writes must propagate failure");
22314            assert_eq!(client.schema.version, 1);
22315            assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
22316            assert_eq!(
22317                client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
22318                descriptor
22319            );
22320            client
22321                .conn
22322                .execute_batch("DROP TRIGGER refuse_schema_record")
22323                .expect("remove fault");
22324            client
22325                .recreate_with_schema(&previous_version_schema(2))
22326                .expect("retry");
22327            assert_eq!(client.schema.version, 2);
22328        }
22329    }
22330
22331    #[test]
22332    fn a_failed_outer_commit_restores_every_reset_side_effect() {
22333        let path = temp_replica("outer-commit");
22334        let path_str = path.to_str().expect("path");
22335        // A rollback-journal replica (no WAL), so a second read connection can
22336        // hold SHARED across the recreated transaction's COMMIT.
22337        let conn = Connection::open(path_str).expect("replica connection");
22338        let mut client = SyncClient::with_connection(
22339            "outer-commit".into(),
22340            &previous_version_schema(1),
22341            previous_version_limits(),
22342            conn,
22343        )
22344        .expect("v1 replica");
22345        assert_eq!(journal_mode(&client.conn), "delete");
22346        let commit = client
22347            .mutate(vec![Mutation::Upsert {
22348                table: "tasks".into(),
22349                values: Map::from_iter([
22350                    ("id".into(), json!("queued")),
22351                    ("note".into(), json!("dropped-by-v2")),
22352                ]),
22353                base_version: None,
22354            }])
22355            .expect("queued write");
22356        client.drain_change_batches();
22357        // v2 drops `note`, so the queued upsert cannot re-encode.
22358        let mut dropped = previous_version_schema(2);
22359        dropped["tables"][0]["columns"] =
22360            json!([{ "name": "id", "type": "string", "nullable": false }]);
22361        // Prior database and memory state: the refused COMMIT must restore all
22362        // of it.
22363        let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
22364        let revision = client.local_revision();
22365        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
22366        let columns: i64 = client
22367            .conn
22368            .query_row(
22369                "SELECT count(*) FROM pragma_table_info('tasks')",
22370                [],
22371                |row| row.get(0),
22372            )
22373            .expect("column count");
22374        let rebuilds = client.overlay_rebuild_count.get();
22375        let prior_subs = client.subs.len();
22376        let prior_changes = client.change_queue.len();
22377        let prior_intents = client.sync_intent_queue.len();
22378        let prior_last_change = client.last_change.clone();
22379        let prior_overlay_dirty = client.overlay_dirty.get();
22380        let prior_sync_needed = client.sync_needed;
22381        let prior_upgrading = client.upgrading;
22382        let prior_stopped = client.stopped;
22383        let prior_schema_floor = client.schema_floor.clone();
22384        let prior_active_round = client.active_round;
22385        // The reader holds SHARED for the whole attempt: the writer takes
22386        // RESERVED, runs the reset, and fails when COMMIT needs EXCLUSIVE.
22387        let reader = Connection::open(path_str).expect("reader");
22388        reader
22389            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22390            .expect("reader snapshot");
22391        client
22392            .conn
22393            .busy_timeout(std::time::Duration::ZERO)
22394            .expect("busy timeout");
22395        let error = client
22396            .recreate_with_schema(&dropped)
22397            .expect_err("a blocked COMMIT must refuse the recreation");
22398        assert!(error.starts_with("client.storage_busy:"), "{error}");
22399        assert!(
22400            client.overlay_rebuild_count.get() > rebuilds,
22401            "the reset ran to its overlay rebuild inside the transaction"
22402        );
22403        assert!(client.conn.is_autocommit(), "the transaction is released");
22404        // Database: the rolled-back reset left nothing behind.
22405        assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
22406        assert_eq!(client.local_revision(), revision);
22407        assert_eq!(
22408            client.query("SELECT * FROM tasks", &[]).expect("rows"),
22409            rows
22410        );
22411        assert_eq!(
22412            client
22413                .conn
22414                .query_row(
22415                    "SELECT count(*) FROM pragma_table_info('tasks')",
22416                    [],
22417                    |row| { row.get::<_, i64>(0) }
22418                )
22419                .expect("column count"),
22420            columns,
22421            "the previous table layout survives the rollback"
22422        );
22423        assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
22424        // Memory: every side effect of the successful reset is restored with
22425        // the rolled-back database.
22426        assert_eq!(client.schema.version, 1, "compiled schema");
22427        assert_eq!(client.subs.len(), prior_subs);
22428        assert_eq!(client.change_queue.len(), prior_changes);
22429        assert_eq!(client.sync_intent_queue.len(), prior_intents);
22430        assert_eq!(client.last_change, prior_last_change);
22431        assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
22432        assert_eq!(client.sync_needed, prior_sync_needed);
22433        assert_eq!(client.upgrading, prior_upgrading);
22434        assert_eq!(client.stopped, prior_stopped);
22435        assert_eq!(client.schema_floor, prior_schema_floor);
22436        assert_eq!(client.active_round, prior_active_round);
22437        assert!(
22438            client.rejections.is_empty(),
22439            "the dropped-column rejection is rolled back too"
22440        );
22441        // The capture committed in its separate file before the failed replica
22442        // COMMIT. It belongs to v2 and cannot be exposed by the restored v1 core.
22443        let container = crate::previous_version::previous_version_container_path(path_str);
22444        assert!(std::path::Path::new(&container).exists());
22445        // The reader no longer needs its snapshot, so discard can clean the
22446        // replica's audit records along with the stale sibling file.
22447        drop(reader);
22448        let snapshot = client
22449            .previous_version_snapshot(&PreviousVersionReadSpec {
22450                table: "tasks".into(),
22451                ..Default::default()
22452            })
22453            .expect("refuse stale capture");
22454        assert!(!snapshot.available);
22455        assert_eq!(snapshot.current_version, 1);
22456        assert!(!std::path::Path::new(&container).exists());
22457        // The client remains usable: releasing the reader lets the identical
22458        // recreation run to completion, and the reset preserves the
22459        // incompatible intent until the send-time prepass classifies it
22460        // (§7.4.3/§7.4.4: the reset never touches the outbox).
22461        client
22462            .conn
22463            .busy_timeout(std::time::Duration::from_secs(1))
22464            .expect("busy timeout");
22465        client
22466            .recreate_with_schema(&dropped)
22467            .expect("the recreation succeeds once the reader releases");
22468        assert_eq!(client.schema.version, 2);
22469        assert_eq!(
22470            client.pending_commit_ids(),
22471            vec![commit.clone()],
22472            "the reset kept the incompatible commit pending"
22473        );
22474        assert!(
22475            client.rejections.is_empty(),
22476            "no rejection exists before the send-time prepass"
22477        );
22478        // The send boundary classifies and drops it.
22479        client
22480            .prepare_sync_round(false)
22481            .expect("the send prepass runs during round preparation");
22482        assert!(client.pending_commit_ids().is_empty());
22483        assert!(
22484            client
22485                .rejections
22486                .iter()
22487                .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
22488            "the dropped-column commit surfaced as a rejection at send time"
22489        );
22490        client.previous_version_discard();
22491        drop(client);
22492        std::fs::remove_file(path).expect("remove replica");
22493    }
22494
22495    #[test]
22496    fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
22497        let v1 = previous_version_schema(1);
22498        let mut v2 = previous_version_schema(2);
22499        // v2 drops `note`, so the queued `dropped` upsert cannot re-encode.
22500        v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
22501        let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
22502        let dropped = client
22503            .mutate(vec![Mutation::Upsert {
22504                table: "tasks".into(),
22505                values: Map::from_iter([
22506                    ("id".into(), json!("dropped")),
22507                    ("note".into(), json!("gone")),
22508                ]),
22509                base_version: None,
22510            }])
22511            .unwrap();
22512        // A full-row v1 upsert always carries the v1 columns, so the surviving
22513        // commit is a value-free delete (it encodes under any schema that still
22514        // has the table).
22515        let survivor = client
22516            .mutate(vec![Mutation::Delete {
22517                table: "tasks".into(),
22518                row_id: "gone".into(),
22519                base_version: None,
22520            }])
22521            .unwrap();
22522        client
22523            .recreate_with_schema(&v2)
22524            .expect("the reset preserves the outbox");
22525        assert_eq!(
22526            client.pending_commit_ids(),
22527            vec![dropped.clone(), survivor.clone()],
22528            "the reset preserves both commits"
22529        );
22530        assert!(client.rejections.is_empty(), "the reset classifies nothing");
22531        client.drain_change_batches();
22532        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22533        assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
22534
22535        // A fault anywhere in the atomic drop leaves the replica, the outbox,
22536        // the durable outcomes, the visible projection, the revision, and the
22537        // publish surface untouched. Clearing `syncNeeded` first keeps the
22538        // round's own status flip out of the assertion, so any published change
22539        // can only be the drop's.
22540        for fault in [
22541            "outcome-insert",
22542            "outbox-delete",
22543            "overlay-rebuild",
22544            "publish-revision",
22545        ] {
22546            client.set_sync_needed(false, false);
22547            client.drain_change_batches();
22548            let revision = client.local_revision();
22549            let trigger = match fault {
22550                "outcome-insert" => {
22551                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22552                }
22553                "outbox-delete" => {
22554                    "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22555                }
22556                // The optimized replay removes only the dropped projection, so
22557                // the replay fault belongs on the DELETE; a publish fault lands
22558                // on the revision write that `finish_observation` performs after
22559                // every durable effect already succeeded.
22560                "publish-revision" => {
22561                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22562                }
22563                _ => {
22564                    "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22565                }
22566            };
22567            client.conn.execute_batch(trigger).unwrap();
22568            let outcome = client
22569                .prepare_sync_round(false)
22570                .err()
22571                .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
22572            assert!(
22573                matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
22574                "{fault}: {outcome:?}"
22575            );
22576            client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
22577            assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
22578            assert_eq!(
22579                client.pending_commit_ids(),
22580                vec![dropped.clone(), survivor.clone()],
22581                "{fault}: the outbox is restored"
22582            );
22583            assert!(
22584                client.rejections.is_empty(),
22585                "{fault}: no in-memory rejection survives"
22586            );
22587            assert!(
22588                client.commit_outcome(&dropped).unwrap().is_none(),
22589                "{fault}: the durable outcome rolled back"
22590            );
22591            assert_eq!(client.local_revision(), revision, "{fault}: revision");
22592            assert_eq!(
22593                client.query("SELECT * FROM tasks", &[]).unwrap(),
22594                rows,
22595                "{fault}: the visible projection is unchanged"
22596            );
22597            assert!(
22598                client.drain_change_batches().is_empty(),
22599                "{fault}: nothing is published"
22600            );
22601        }
22602
22603        // A nested failure leaves the caller's transaction and earlier writes
22604        // intact; only the drop's savepoint is rolled back.
22605        client
22606            .conn
22607            .execute_batch(
22608                "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
22609             CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
22610             BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
22611            )
22612            .unwrap();
22613        assert!(client.drop_incompatible_outbox().is_err());
22614        assert!(!client.conn.is_autocommit());
22615        assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
22616        assert_eq!(
22617            client.pending_commit_ids(),
22618            vec![dropped.clone(), survivor.clone()]
22619        );
22620        assert!(client.commit_outcome(&dropped).unwrap().is_none());
22621        client
22622            .conn
22623            .execute_batch("DROP TRIGGER fail_drop; COMMIT")
22624            .unwrap();
22625
22626        // The successful drop publishes exactly once and leaves the surviving
22627        // intent queued for the same round. A log epoch is required before a
22628        // round builds push frames at all, so the classification provably runs
22629        // before any operation of the dropped commit is sent.
22630        client.set_meta(LOG_EPOCH_KEY, "epoch");
22631        client.set_sync_needed(false, false);
22632        client.drain_change_batches();
22633        let revision = client.local_revision();
22634        let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
22635        assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
22636        assert_eq!(
22637            prepared.meta.pushed_ids,
22638            vec![survivor.clone()],
22639            "the incompatible commit is classified before any operation is sent"
22640        );
22641        let rejection = client
22642            .rejections
22643            .iter()
22644            .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
22645            .expect("a sync.outbox_incompatible rejection");
22646        assert_eq!(rejection.client_commit_id, dropped);
22647        assert!(
22648            !rejection.retryable,
22649            "a schema-incompatible commit is final"
22650        );
22651        assert_eq!(
22652            client
22653                .commit_outcome(&dropped)
22654                .unwrap()
22655                .expect("a durable rejected outcome")
22656                .status,
22657            CommitOutcomeStatus::Rejected
22658        );
22659        assert_eq!(client.local_revision(), revision + 1, "one revision");
22660        let batches = client.drain_change_batches();
22661        assert_eq!(batches.len(), 1, "one change batch");
22662        assert!(
22663            batches[0].rejections_changed
22664                && batches[0].outcomes_changed
22665                && batches[0].status.is_some(),
22666            "{:?}",
22667            batches[0]
22668        );
22669        assert!(
22670            batches[0].tables.iter().any(|entry| entry.table == "tasks"),
22671            "{:?}",
22672            batches[0]
22673        );
22674        let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
22675        assert!(
22676            visible.is_empty(),
22677            "the dropped projection is undone: {visible:?}"
22678        );
22679
22680        // The observation's outer RELEASE (transaction COMMIT) is guarded too:
22681        // a rollback-journal reader holding SHARED makes it fail with the typed
22682        // busy classification, and releasing the reader lets the retry succeed.
22683        let path = temp_replica("outbox-drop-commit");
22684        let path_str = path.to_str().expect("path");
22685        let conn = Connection::open(path_str).expect("replica connection");
22686        let mut client =
22687            SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
22688                .expect("v1 replica");
22689        let dropped = client
22690            .mutate(vec![Mutation::Upsert {
22691                table: "tasks".into(),
22692                values: Map::from_iter([
22693                    ("id".into(), json!("dropped")),
22694                    ("note".into(), json!("gone")),
22695                ]),
22696                base_version: None,
22697            }])
22698            .unwrap();
22699        client
22700            .recreate_with_schema(&v2)
22701            .expect("the reset preserves the outbox");
22702        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22703        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22704        assert_eq!(rows.len(), 1, "the optimistic row is visible");
22705        client.set_sync_needed(false, false);
22706        client.drain_change_batches();
22707        let revision = client.local_revision();
22708        let reader = Connection::open(path_str).expect("reader");
22709        reader
22710            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22711            .expect("reader snapshot");
22712        client
22713            .conn
22714            .busy_timeout(std::time::Duration::ZERO)
22715            .expect("busy timeout");
22716        let outcome = client
22717            .prepare_sync_round(false)
22718            .err()
22719            .expect("a blocked COMMIT must fail the round");
22720        assert!(
22721            matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
22722            "{outcome:?}"
22723        );
22724        assert!(client.conn.is_autocommit(), "the savepoint is released");
22725        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22726        assert!(client.rejections.is_empty());
22727        assert!(client.commit_outcome(&dropped).unwrap().is_none());
22728        assert_eq!(client.local_revision(), revision);
22729        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
22730        assert!(client.drain_change_batches().is_empty());
22731        // The same round succeeds once the reader releases.
22732        drop(reader);
22733        client
22734            .conn
22735            .busy_timeout(std::time::Duration::from_secs(1))
22736            .expect("busy timeout");
22737        client
22738            .prepare_sync_round(false)
22739            .expect("the drop succeeds after the reader releases");
22740        assert!(client.pending_commit_ids().is_empty());
22741        assert!(client
22742            .rejections
22743            .iter()
22744            .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
22745        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
22746        drop(client);
22747        std::fs::remove_file(path).expect("remove replica");
22748    }
22749
22750    #[test]
22751    fn previous_version_read_reasons_come_from_live_client_state() {
22752        use crate::previous_version::{
22753            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
22754        };
22755        let path = std::env::temp_dir()
22756            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
22757            .to_string_lossy()
22758            .into_owned();
22759        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
22760        {
22761            let mut setup = SyncClient::open_path(
22762                "wiring".to_owned(),
22763                &previous_version_schema(1),
22764                previous_version_limits(),
22765                &path,
22766            )
22767            .expect("v1 install");
22768            setup
22769                .mutate(vec![Mutation::Upsert {
22770                    table: "tasks".to_owned(),
22771                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22772                    base_version: None,
22773                }])
22774                .expect("seed a row");
22775        }
22776        let mut client = SyncClient::open_path(
22777            "wiring".to_owned(),
22778            &previous_version_schema(2),
22779            previous_version_limits(),
22780            &path,
22781        )
22782        .expect("v2 bump captures");
22783        assert!(std::path::Path::new(&container).exists());
22784        let spec = PreviousVersionReadSpec {
22785            table: "tasks".to_owned(),
22786            ..Default::default()
22787        };
22788
22789        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
22790        recapture_previous_version(&client);
22791        client.lease_state = Some(LeaseState {
22792            lease_id: None,
22793            expires_at_ms: None,
22794            error_code: Some("lease.expired".to_owned()),
22795        });
22796        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22797        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22798        assert!(!snapshot.available);
22799        assert!(!std::path::Path::new(&container).exists());
22800
22801        // An expired lease uses the client clock (injected, no sleeps).
22802        recapture_previous_version(&client);
22803        client.set_now_ms(1_000);
22804        client.lease_state = Some(LeaseState {
22805            lease_id: Some("lease".to_owned()),
22806            expires_at_ms: Some(1_000),
22807            error_code: None,
22808        });
22809        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22810        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22811        assert!(!std::path::Path::new(&container).exists());
22812        client.lease_state = None;
22813
22814        // A revoked subscription ⇒ scope-revoked.
22815        recapture_previous_version(&client);
22816        client.subs.push(subscription(SubState::Revoked, 0, None));
22817        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22818        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
22819        assert!(!std::path::Path::new(&container).exists());
22820        client.subs.clear();
22821
22822        // Coverage completion: every ACTIVE subscription has a cursor and no
22823        // resume token — a still-resuming subscription is not completion.
22824        recapture_previous_version(&client);
22825        client
22826            .subs
22827            .push(subscription(SubState::Active, 0, Some("resume")));
22828        assert!(!client.previous_version_coverage_complete());
22829        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22830        assert!(snapshot.available);
22831        assert_eq!(snapshot.previous_version, Some(2));
22832        assert_eq!(snapshot.rows.len(), 1);
22833
22834        recapture_previous_version(&client);
22835        client.subs[0].bootstrap_state = None;
22836        assert!(client.previous_version_coverage_complete());
22837        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22838        assert_eq!(
22839            snapshot.reason,
22840            Some(PreviousVersionReason::CoverageComplete)
22841        );
22842        assert!(!std::path::Path::new(&container).exists());
22843        client.subs.clear();
22844
22845        // With no active subscription at all, coverage is NOT complete.
22846        assert!(!client.previous_version_coverage_complete());
22847
22848        drop(client);
22849        let _ = std::fs::remove_file(&path);
22850    }
22851
22852    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
22853    /// come from live state; this proves the OTHER caller —
22854    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
22855    /// actually removes the container for lease-inactivity, coverage
22856    /// completion and scope revocation, and leaves it alone while the
22857    /// replacement bootstrap is still resuming.
22858    #[test]
22859    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
22860        let path = std::env::temp_dir()
22861            .join(format!(
22862                "syncular-prev-lifetime-{}.db",
22863                uuid::Uuid::new_v4()
22864            ))
22865            .to_string_lossy()
22866            .into_owned();
22867        let container = format!(
22868            "{path}{}",
22869            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
22870        );
22871        let exists = || std::path::Path::new(&container).exists();
22872        {
22873            let mut setup = SyncClient::open_path(
22874                "lifetime".to_owned(),
22875                &previous_version_schema(1),
22876                previous_version_limits(),
22877                &path,
22878            )
22879            .expect("v1 install");
22880            setup
22881                .mutate(vec![Mutation::Upsert {
22882                    table: "tasks".to_owned(),
22883                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22884                    base_version: None,
22885                }])
22886                .expect("seed a row");
22887        }
22888        let mut client = SyncClient::open_path(
22889            "lifetime".to_owned(),
22890            &previous_version_schema(2),
22891            previous_version_limits(),
22892            &path,
22893        )
22894        .expect("v2 bump captures");
22895        // Pin the clock so the 24h TTL never fires in this test (the TTL has
22896        // its own coverage); the capture record's createdAtMs is 1.
22897        client.set_now_ms(1_000);
22898        assert!(exists());
22899
22900        // §7.3.5 lease error ⇒ the lifetime check drops the container.
22901        client.lease_state = Some(LeaseState {
22902            lease_id: None,
22903            expires_at_ms: None,
22904            error_code: Some("lease.expired".to_owned()),
22905        });
22906        client.previous_version_lifetime_check();
22907        assert!(!exists());
22908        client.lease_state = None;
22909
22910        // §7.4.5 coverage completion (every active sub has a cursor and no
22911        // resume token) ⇒ drop.
22912        recapture_previous_version(&client);
22913        client.subs.push(subscription(SubState::Active, 0, None));
22914        client.previous_version_lifetime_check();
22915        assert!(!exists());
22916        client.subs.clear();
22917
22918        // §3.3 scope revocation ⇒ drop.
22919        recapture_previous_version(&client);
22920        client.subs.push(subscription(SubState::Revoked, 0, None));
22921        client.previous_version_lifetime_check();
22922        assert!(!exists());
22923        client.subs.clear();
22924
22925        // Negative control: a still-resuming active subscription is NOT
22926        // coverage completion, so the container survives the same call.
22927        recapture_previous_version(&client);
22928        client
22929            .subs
22930            .push(subscription(SubState::Active, 0, Some("resume")));
22931        client.previous_version_lifetime_check();
22932        assert!(exists());
22933        client.subs.clear();
22934
22935        drop(client);
22936        let _ = std::fs::remove_file(&path);
22937        let _ = std::fs::remove_file(&container);
22938        let _ = std::fs::remove_file(format!("{container}-wal"));
22939        let _ = std::fs::remove_file(format!("{container}-shm"));
22940    }
22941}
22942
22943#[cfg(test)]
22944mod scope_move_delivery_tests {
22945    use super::*;
22946    use crate::native_transport::HostTransport;
22947    use crate::values::encode_row_json;
22948    use serde_json::json;
22949
22950    fn client() -> SyncClient {
22951        SyncClient::new(
22952            "scope-move".to_owned(),
22953            &json!({
22954                "version": 1,
22955                "tables": [{
22956                    "name": "tasks",
22957                    "primaryKey": "id",
22958                    "columns": [
22959                        { "name": "id", "type": "string", "nullable": false },
22960                        { "name": "project_id", "type": "string", "nullable": false }
22961                    ],
22962                    "scopes": [{ "pattern": "project:{project_id}" }]
22963                }]
22964            }),
22965            ClientLimits::default(),
22966        )
22967        .expect("test client")
22968    }
22969
22970    fn upsert(client: &SyncClient, project: &str) -> ssp2::model::Change {
22971        ssp2::model::Change {
22972            table_index: 0,
22973            row_id: "t1".to_owned(),
22974            op: Op::Upsert,
22975            row_version: Some(1),
22976            scopes: vec![("project_id".to_owned(), project.to_owned())],
22977            row: Some(
22978                encode_row_json(
22979                    client.schema.table("tasks").unwrap(),
22980                    "t1",
22981                    &Map::from_iter([
22982                        ("id".to_owned(), Value::from("t1")),
22983                        ("project_id".to_owned(), Value::from(project)),
22984                    ]),
22985                    &client.encryption,
22986                )
22987                .unwrap(),
22988            ),
22989        }
22990    }
22991
22992    fn delete(project: &str) -> ssp2::model::Change {
22993        ssp2::model::Change {
22994            table_index: 0,
22995            row_id: "t1".to_owned(),
22996            op: Op::Delete,
22997            row_version: None,
22998            scopes: vec![("project_id".to_owned(), project.to_owned())],
22999            row: None,
23000        }
23001    }
23002
23003    #[test]
23004    fn a_scope_move_keeps_the_row_in_either_section_order() {
23005        for order in [["old", "new"], ["new", "old"]] {
23006            let mut client = client();
23007            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23008            client
23009                .subscribe(
23010                    "old".into(),
23011                    "tasks".into(),
23012                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
23013                    None,
23014                )
23015                .unwrap();
23016            client
23017                .subscribe(
23018                    "new".into(),
23019                    "tasks".into(),
23020                    vec![("project_id".to_owned(), vec!["p2".to_owned()])],
23021                    None,
23022                )
23023                .unwrap();
23024            let seed = upsert(&client, "p1");
23025            client
23026                .apply_commit_frame(&["tasks".into()], &[seed], Some(1))
23027                .unwrap();
23028            assert_eq!(
23029                client.query("SELECT project_id FROM tasks", &[]).unwrap()[0]["project_id"],
23030                json!("p1")
23031            );
23032
23033            let mut frames = vec![Frame::RespHeader {
23034                required_schema_version: None,
23035                latest_schema_version: None,
23036                log_epoch: Some("epoch-1".to_owned()),
23037                reset_required: Some(false),
23038            }];
23039            for id in order {
23040                let project = if id == "old" { "p1" } else { "p2" };
23041                frames.push(Frame::SubStart {
23042                    id: id.to_owned(),
23043                    status: SubStatus::Active,
23044                    reason_code: String::new(),
23045                    effective_scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
23046                    bootstrap: false,
23047                });
23048                frames.push(Frame::Commit {
23049                    commit_seq: 2,
23050                    created_at_ms: 0,
23051                    actor_id: "actor".to_owned(),
23052                    tables: vec!["tasks".to_owned()],
23053                    changes: vec![if id == "old" {
23054                        delete("p1")
23055                    } else {
23056                        upsert(&client, "p2")
23057                    }],
23058                });
23059                frames.push(Frame::SubEnd {
23060                    next_cursor: 2,
23061                    bootstrap_state: None,
23062                });
23063            }
23064            let (_, meta) = client.build_request(false).unwrap();
23065            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
23066            let outcome = client.process_response(
23067                &mut transport,
23068                Message {
23069                    wire_version: WIRE_VERSION,
23070                    msg_kind: MsgKind::Response,
23071                    frames,
23072                },
23073                &meta,
23074            );
23075            assert!(
23076                matches!(outcome, SyncOutcome::Ok(_)),
23077                "{order:?}: {outcome:?}"
23078            );
23079            let rows = client
23080                .query("SELECT id, project_id FROM tasks", &[])
23081                .unwrap();
23082            assert_eq!(rows.len(), 1, "{order:?}: the row was lost");
23083            assert_eq!(rows[0]["project_id"], json!("p2"), "{order:?}");
23084        }
23085    }
23086
23087    struct RefusalTransport;
23088
23089    impl Transport for RefusalTransport {
23090        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
23091            let mut error = TransportError::new(
23092                "sync.invalid_client_id",
23093                "clientId is bound to a different actor in this partition",
23094            );
23095            error.details = Some(json!({
23096                "causeKind": "status",
23097                "httpStatus": 400,
23098                "category": "invalid-request",
23099                "retryable": false,
23100                "recommendedAction": "resetClientId"
23101            }));
23102            Err(error)
23103        }
23104
23105        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
23106            self.sync(request)
23107        }
23108
23109        fn download_segment(
23110            &mut self,
23111            _request: &SegmentRequest,
23112            _on_progress: &mut dyn FnMut(u64),
23113        ) -> Result<Vec<u8>, TransportError> {
23114            Err(TransportError::new("sync.transport_failed", "offline"))
23115        }
23116
23117        fn realtime_connect(&mut self) -> Result<(), TransportError> {
23118            Ok(())
23119        }
23120
23121        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
23122            Ok(())
23123        }
23124
23125        fn realtime_close(&mut self) -> Result<(), TransportError> {
23126            Ok(())
23127        }
23128    }
23129
23130    #[test]
23131    fn an_http_refusal_carries_its_code_and_recommended_action() {
23132        let mut client = client();
23133        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23134        let outcome = client.sync(&mut RefusalTransport);
23135        let SyncOutcome::Failed {
23136            error_code,
23137            recommended_action,
23138            details,
23139            ..
23140        } = outcome
23141        else {
23142            panic!("expected a failed round");
23143        };
23144        assert_eq!(error_code, "sync.invalid_client_id");
23145        assert_eq!(recommended_action.as_deref(), Some("resetClientId"));
23146        assert_eq!(
23147            details
23148                .as_ref()
23149                .and_then(|details| details.get("httpStatus")),
23150            Some(&json!(400))
23151        );
23152    }
23153
23154    #[test]
23155    fn reset_client_id_persists_and_changes_the_request_identity() {
23156        let mut client = client();
23157        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23158        let previous = client.client_id().to_owned();
23159        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
23160        let next = client.reset_client_id(&mut transport);
23161        assert_ne!(next, previous);
23162        assert_eq!(client.client_id(), next);
23163        assert_eq!(client.get_meta(CLIENT_ID_KEY), Some(next.clone()));
23164
23165        client
23166            .subscribe(
23167                "tasks".into(),
23168                "tasks".into(),
23169                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
23170                None,
23171            )
23172            .unwrap();
23173        let (message, _) = client.build_request(false).unwrap();
23174        assert!(matches!(
23175            message.frames.first(),
23176            Some(Frame::ReqHeader { client_id, .. }) if client_id == &next
23177        ));
23178    }
23179}