Skip to main content

syncular_client/
client.rs

1//! The Syncular v2 Rust client core (SPEC.md client-behavior contract):
2//! rusqlite local storage, §3.2/§3.3 effective-scope persistence + purge,
3//! §4 pull/cursor/bootstrap (§4.7 resume, §5.6 segment application), §6
4//! push with outbox order, §7 optimistic apply / rollback / replay-on-top,
5//! §2.3 clientCommitId idempotency, §8 realtime client rules, §10 errors.
6//!
7//! Built from `SPEC.md` and the committed `ssp2` codec alone — no
8//! reference to the v1 Rust tree or the v2 TypeScript client.
9
10use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use ssp2::encode_message;
12use std::borrow::Cow;
13#[cfg(test)]
14use std::cell::Cell;
15use std::cell::RefCell;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
17
18use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, OptionalExtension};
20use serde::{Deserialize, Serialize};
21use serde_json::{json, Map, Value};
22use sha2::{Digest, Sha256};
23use ssp2::decode::WIRE_VERSION;
24use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
25use ssp2::primitives::RawJson;
26use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
27use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
28
29use crate::api::{
30    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
31    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
32    ClientDiagnosticsStorage, ClientError, ClientLimits, CommandEffects, CommitDelivery,
33    CommitOperation, CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery,
34    CommitOutcomeResolution, CommitOutcomeStatus, ConflictRecord, CoverageSnapshot,
35    DiagnosticLastChange, DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters,
36    DiagnosticSubscription, FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult,
37    LocalDataPurgeTarget, LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation,
38    PresencePeer, PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot,
39    QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
40    RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
41    SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
42    SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
43    WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
44    MAX_DIAGNOSTIC_QUERY_FAILURES,
45};
46use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
47#[cfg(feature = "bench-internals")]
48use crate::bench::{Phase, Recorder};
49use crate::previous_version::{
50    capture_previous_version_from_replica, first_incompatibility,
51    reconcile_previous_version_at_boot, set_local_schema_descriptor,
52    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
53    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
54};
55use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
56use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
57use crate::values::{
58    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
59    encode_sparse_row_json, full_row_values, is_canonical_bytes_envelope, json_to_column_value,
60    json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
61    sort_scope_map,
62};
63
64/// §4.2 default: rows and SQLite images, both applied in committed chunks.
65/// Bit 3 (signed URLs, §5.4) is
66/// added per transport capability at request-build time.
67const DEFAULT_ACCEPT: u8 = 0b0111;
68const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
69const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
70const ACCEPT_SQLITE: u8 = 1 << 2;
71const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
72const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
73
74/// §10.1 `recommendedAction` carried by a server HTTP error body.
75fn recommended_action_of(details: &Option<Value>) -> Option<String> {
76    details
77        .as_ref()?
78        .get("recommendedAction")?
79        .as_str()
80        .map(str::to_owned)
81}
82
83/// §2.2 scope migration: one commit carries the delete under the old scope and
84/// the upsert under the new one. A reader that holds both scopes receives the
85/// two changes in separate subscription sections, so section order would
86/// otherwise decide whether the row survives. Collect the `(commitSeq, table,
87/// rowId)` keys whose delete must be skipped because the same commit delivers
88/// the row's upsert in another applied section. A section that carries both
89/// changes itself keeps its own operation order.
90fn coalesced_delete_keys(
91    frames: &[Frame],
92    mut applies: impl FnMut(&str, SubStatus, Option<i64>) -> bool,
93) -> HashSet<(i64, String, String)> {
94    let mut sections = Vec::new();
95    let mut open: Option<(usize, String, SubStatus)> = None;
96    for (index, frame) in frames.iter().enumerate() {
97        match frame {
98            Frame::SubStart { id, status, .. } => {
99                open = Some((index, id.clone(), *status));
100            }
101            Frame::SubEnd { next_cursor, .. } => {
102                if let Some((start, id, status)) = open.take() {
103                    sections.push((start, index, id, status, Some(*next_cursor)));
104                }
105            }
106            _ => {}
107        }
108    }
109    type Key = (i64, String, String);
110    let mut ops: HashMap<Key, (HashSet<usize>, HashSet<usize>)> = HashMap::new();
111    let mut both_in_frame: HashSet<Key> = HashSet::new();
112    let mut frame_ordinal = 0usize;
113    for (start, end, id, status, next_cursor) in sections {
114        if !applies(&id, status, next_cursor) {
115            continue;
116        }
117        for frame in &frames[start + 1..end] {
118            let Frame::Commit {
119                commit_seq,
120                tables,
121                changes,
122                ..
123            } = frame
124            else {
125                continue;
126            };
127            frame_ordinal += 1;
128            let mut local: HashMap<(String, String), (bool, bool)> = HashMap::new();
129            for change in changes {
130                let Some(table) = tables.get(change.table_index as usize) else {
131                    continue;
132                };
133                let key = (*commit_seq, table.clone(), change.row_id.clone());
134                let entry = ops.entry(key).or_default();
135                let local_entry = local
136                    .entry((table.clone(), change.row_id.clone()))
137                    .or_default();
138                match change.op {
139                    Op::Upsert => {
140                        entry.0.insert(frame_ordinal);
141                        local_entry.0 = true;
142                    }
143                    Op::Delete => {
144                        entry.1.insert(frame_ordinal);
145                        local_entry.1 = true;
146                    }
147                }
148            }
149            for ((table, row_id), (has_upsert, has_delete)) in local {
150                if has_upsert && has_delete {
151                    both_in_frame.insert((*commit_seq, table, row_id));
152                }
153            }
154        }
155    }
156    ops.into_iter()
157        .filter_map(|(key, (upserts, deletes))| {
158            (!both_in_frame.contains(&key) && !upserts.is_empty() && !deletes.is_empty())
159                .then_some(key)
160        })
161        .collect()
162}
163
164/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
165pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
166const LOCAL_REVISION_KEY: &str = "localRevision";
167const CLIENT_ID_KEY: &str = "clientId";
168const LEASE_STATE_KEY: &str = "leaseState";
169const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
170pub(crate) const LOG_EPOCH_KEY: &str = "logEpoch";
171/// Persisted fail-closed quarantine marker: present while a security preflight
172/// is pending and `activateSecurity` has yet to run. Storing it in the replica
173/// keeps the gate with the data it protects, so a rebuilt host handle reopening
174/// the same file returns in preflight even though its in-memory state is fresh.
175const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
176const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
177const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
178/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
179/// the new schema after a bump. Never a wire code (§10.3).
180const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
181/// §5.11 client-local code: an encode at the push seam resolved no usable key
182/// id or named an unknown key. Never a wire code (§10.3).
183const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
184const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
185/// §7.4.4: static text for a persisted commit the current codec refuses.
186const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
187    "the persisted commit carries values the current codec refuses";
188/// §7.4.4: bounded reason on every intrinsic value drop.
189const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
190
191#[derive(Debug, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193struct PersistedLocalDataRebootstrapReceipt {
194    version: u8,
195    retained_commits: u64,
196    reset_subscriptions: u64,
197}
198
199fn invalid_local_rebootstrap_receipt() -> String {
200    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
201}
202
203fn encode_local_rebootstrap_receipt(
204    retained_commits: usize,
205    reset_subscriptions: usize,
206) -> Result<String, String> {
207    let retained_commits =
208        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
209    let reset_subscriptions =
210        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
211    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
212        return Err(invalid_local_rebootstrap_receipt());
213    }
214    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
215        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
216        retained_commits,
217        reset_subscriptions,
218    })
219    .map_err(|_| invalid_local_rebootstrap_receipt())
220}
221
222fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
223    // Pre-0.15.36 markers proved application but did not retain the receipt.
224    if value == "v1" {
225        return Ok((0, 0));
226    }
227    let receipt: PersistedLocalDataRebootstrapReceipt =
228        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
229    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
230        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
231        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
232    {
233        return Err(invalid_local_rebootstrap_receipt());
234    }
235    Ok((
236        usize::try_from(receipt.retained_commits)
237            .map_err(|_| invalid_local_rebootstrap_receipt())?,
238        usize::try_from(receipt.reset_subscriptions)
239            .map_err(|_| invalid_local_rebootstrap_receipt())?,
240    ))
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244enum SubState {
245    Active,
246    Revoked,
247    Failed,
248}
249
250#[cfg(test)]
251mod observation_tests {
252    use super::*;
253    use crate::native_transport::HostTransport;
254    use crate::values::encode_row_json;
255    use crate::BlobUploadGrant;
256    use serde_json::json;
257
258    #[test]
259    fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
260        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
261            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
262        ]}]});
263        for name in [
264            "secret",
265            "missing",
266            "*",
267            "id FROM authority; DELETE FROM authority",
268            "_syncular_version",
269        ] {
270            let limits = ClientLimits {
271                authority_reads: vec![crate::api::AuthorityReadDeclaration {
272                    table: "authority".into(),
273                    columns: vec!["id".into(), "actor_id".into(), name.into()],
274                    scopes: [("actor_id".into(), vec!["a".into()])].into(),
275                }],
276                ..Default::default()
277            };
278            let error = SyncClient::new("authority".into(), &schema, limits)
279                .err()
280                .expect("forbidden policy");
281            assert!(
282                error.starts_with("client.authority_read_forbidden:"),
283                "{error}"
284            );
285        }
286    }
287
288    #[test]
289    fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
290        let path = std::env::temp_dir().join(format!(
291            "syncular-authority-snapshot-{}.db",
292            uuid::Uuid::new_v4()
293        ));
294        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
295            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
296        ]}]});
297        let limits = ClientLimits {
298            authority_reads: vec![crate::api::AuthorityReadDeclaration {
299                table: "authority".into(),
300                columns: vec!["id".into(), "actor_id".into(), "revision".into()],
301                scopes: [("actor_id".into(), vec!["a".into()])].into(),
302            }],
303            ..Default::default()
304        };
305        let mut client =
306            SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
307                .unwrap();
308        client.begin_security_preflight();
309        let update = |conn: &Connection, revision: i64| {
310            conn.execute_batch("BEGIN IMMEDIATE").unwrap();
311            conn.execute(
312                "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
313                [revision],
314            )
315            .unwrap();
316            conn.execute(
317                "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
318                [revision.to_string()],
319            )
320            .unwrap();
321            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();
322            conn.execute_batch("COMMIT").unwrap();
323        };
324        update(&client.conn, 1);
325        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
326        let writer_barrier = barrier.clone();
327        let writer_path = path.clone();
328        let writer = std::thread::spawn(move || {
329            let conn = Connection::open(writer_path).unwrap();
330            conn.busy_timeout(std::time::Duration::from_secs(5))
331                .unwrap();
332            writer_barrier.wait();
333            for revision in 2..=100 {
334                update(&conn, revision);
335            }
336        });
337        barrier.wait();
338        for _ in 0..100 {
339            let snapshot = client.authority_snapshot().unwrap();
340            let revision = snapshot["revision"]
341                .as_str()
342                .unwrap()
343                .parse::<i64>()
344                .unwrap();
345            assert_eq!(
346                snapshot["tables"][0]["rows"][0]["values"]["revision"],
347                revision
348            );
349            assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
350            assert_eq!(snapshot["complete"], true);
351        }
352        writer.join().unwrap();
353        assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
354        drop(client);
355        let _ = std::fs::remove_file(path);
356    }
357
358    fn client() -> SyncClient {
359        SyncClient::new(
360            "retry-test".to_owned(),
361            &json!({
362                "version": 1,
363                "tables": [{
364                    "name": "tasks",
365                    "primaryKey": "id",
366                    "columns": [
367                        { "name": "id", "type": "string", "nullable": false },
368                        { "name": "project_id", "type": "string", "nullable": false }
369                    ],
370                    "scopes": [{ "pattern": "project:{project_id}" }]
371                }]
372            }),
373            ClientLimits::default(),
374        )
375        .expect("test client")
376    }
377
378    fn round_error(outcome: SyncOutcome) -> TransportError {
379        match outcome {
380            SyncOutcome::Failed {
381                error_code,
382                message,
383                details,
384                ..
385            } => TransportError {
386                code: error_code,
387                message,
388                details,
389            },
390            other => TransportError::new(
391                "client.failed",
392                format!("unexpected round outcome: {other:?}"),
393            ),
394        }
395    }
396
397    /// Upload every pinned blob through the split round API and stop before the
398    /// metadata request.
399    fn flush_blob_uploads(
400        client: &mut SyncClient,
401        transport: &mut dyn Transport,
402    ) -> Result<(), TransportError> {
403        let mut prepared = match client.prepare_sync_round(false) {
404            Ok(prepared) => prepared,
405            Err(outcome) => return Err(round_error(*outcome)),
406        };
407        loop {
408            if prepared.uploads.is_empty() {
409                client.cancel_sync_round();
410                return Ok(());
411            }
412            let completed = crate::round::test_support::drive_round(prepared, transport);
413            prepared = match client.apply_sync_round(completed) {
414                crate::AppliedSyncRound::Continue(prepared) => prepared,
415                crate::AppliedSyncRound::Complete { outcome, .. } => {
416                    return match outcome {
417                        SyncOutcome::Ok(_) => Ok(()),
418                        other => Err(round_error(other)),
419                    };
420                }
421            };
422        }
423    }
424
425    #[test]
426    fn subscribe_many_registers_all_or_nothing() {
427        let request = |id: &str, project: &str| SubscriptionRequest {
428            id: id.to_owned(),
429            table: "tasks".to_owned(),
430            scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
431            params: None,
432        };
433        let mut client = client();
434        client
435            .subscribe_many(vec![request("a", "p1"), request("b", "p2")])
436            .unwrap();
437        let persisted = |client: &SyncClient| -> i64 {
438            client
439                .conn
440                .query_row("SELECT count(*) FROM _syncular_subscriptions", [], |row| {
441                    row.get(0)
442                })
443                .unwrap()
444        };
445        assert_eq!(persisted(&client), 2);
446        // `b` names another project: the whole call fails, `c` stays out.
447        let error = client
448            .subscribe_many(vec![request("c", "p3"), request("b", "p9")])
449            .unwrap_err();
450        assert!(error.starts_with("client.subscription_intent_mismatch:"));
451        assert!(client.subscription_state("c").is_none());
452        assert_eq!(persisted(&client), 2);
453        assert!(client.conn.is_autocommit());
454    }
455
456    #[cfg(feature = "e2ee")]
457    #[test]
458    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
459        let schema = json!({
460            "version": 1,
461            "tables": [{
462                "name": "secrets",
463                "primaryKey": "id",
464                "columns": [
465                    { "name": "id", "type": "string", "nullable": false },
466                    { "name": "encryption_key_id", "type": "string", "nullable": true },
467                    { "name": "note", "type": "bytes", "nullable": true,
468                      "encrypted": true, "declaredType": "string" }
469                ],
470                "scopes": []
471            }]
472        });
473        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
474            .expect("test client");
475        let mut config = crate::values::EncryptionConfig::default();
476        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
477        config
478            .key_id_columns
479            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
480        client.set_encryption(config);
481        // The row exists, but its key selector is absent. The encrypted edit
482        // authors locally and fails at the push seam.
483        client
484            .write_base_row(
485                "secrets",
486                &vec![Some(ColumnValue::String("ghost".into())), None, None],
487                1,
488            )
489            .unwrap();
490        client.rebuild_overlay().expect("rebuild overlay");
491        let commit_id = client
492            .patch(
493                "secrets",
494                "ghost",
495                Map::from_iter([("note".to_owned(), json!("x"))]),
496                None,
497            )
498            .expect("patch records without an author-time encrypt failure");
499        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
500        // ...and the push seam raises a durable local rejection, not a throw.
501        assert!(client.drop_unencodable_outbox(true, None).unwrap());
502        assert!(client.pending_commit_ids().is_empty());
503        let rejections = client.rejections();
504        assert_eq!(rejections.len(), 1);
505        assert_eq!(rejections[0].code, "client.encrypt_failed");
506        assert_eq!(rejections[0].client_commit_id, commit_id);
507        assert_eq!(
508            client
509                .commit_outcome(&commit_id)
510                .unwrap()
511                .map(|outcome| outcome.status),
512            Some(CommitOutcomeStatus::Rejected)
513        );
514    }
515
516    #[test]
517    fn patch_scope_read_preserves_sqlite_failure() {
518        let mut client = client();
519        client
520            .conn
521            .execute_batch(
522                "DROP TABLE tasks;
523            CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
524            )
525            .unwrap();
526        let error = client
527            .patch(
528                "tasks",
529                "t1",
530                Map::from_iter([("project_id".to_owned(), json!("p1"))]),
531                None,
532            )
533            .expect_err("the scope read must fail");
534        assert_ne!(error.code, "sync.invalid_request");
535        assert_eq!(error.code, "client.failed");
536        assert!(error.details.as_ref().unwrap()["legacyCause"]
537            .as_str()
538            .unwrap()
539            .contains("integer overflow"));
540        assert!(client.pending_commit_ids().is_empty());
541        assert!(client.conn.is_autocommit());
542    }
543
544    #[test]
545    fn patch_with_a_scope_column_matches_the_stored_local_row() {
546        let mut client = client();
547        client.create_synced_tables().unwrap();
548        client
549            .conn
550            .execute(
551                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
552                [],
553            )
554            .unwrap();
555
556        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
557        // the scope column leaves the presence set.
558        client
559            .patch(
560                "tasks",
561                "t1",
562                Map::from_iter([
563                    ("project_id".to_owned(), json!("p1")),
564                    ("id".to_owned(), json!("t1")),
565                ]),
566                None,
567            )
568            .expect("an equal scope column is a no-op");
569        let values = client.outbox[0].ops[0]
570            .values
571            .clone()
572            .expect("the patch records values");
573        assert_eq!(values.get("id"), Some(&json!("t1")));
574        assert!(!values.contains_key("project_id"));
575
576        // A differing value stays rejected.
577        let differing = client.patch(
578            "tasks",
579            "t1",
580            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
581            None,
582        );
583        let differing = differing.expect_err("a differing scope column is rejected");
584        assert_eq!(differing.code, "sync.invalid_request");
585        assert_eq!(differing.message, "the authoring request is invalid");
586        assert!(
587            differing
588                .details
589                .as_ref()
590                .and_then(|details| details["legacyCause"].as_str())
591                .is_some_and(|cause| cause.contains("patch cannot write scope column")),
592            "{differing:?}"
593        );
594
595        // An absent local row leaves nothing to prove equality against, so
596        // the patch fails closed.
597        let absent = client.patch(
598            "tasks",
599            "ghost",
600            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
601            None,
602        );
603        let absent = absent.expect_err("an absent local row is rejected");
604        assert_eq!(absent.code, "sync.row_missing");
605        assert!(absent.message.contains("requires a local row"));
606        assert_eq!(client.pending_commit_ids().len(), 1);
607    }
608
609    #[test]
610    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
611        let schema = json!({
612            "version": 1,
613            "tables": [
614                {
615                    "name": "tenants",
616                    "primaryKey": "tenant_id",
617                    "columns": [
618                        { "name": "tenant_id", "type": "string", "nullable": false },
619                        { "name": "body", "type": "string", "nullable": false }
620                    ],
621                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
622                },
623                {
624                    "name": "buckets",
625                    "primaryKey": "id",
626                    "columns": [
627                        { "name": "id", "type": "string", "nullable": false },
628                        { "name": "bucket", "type": "float", "nullable": false },
629                        { "name": "body", "type": "string", "nullable": false }
630                    ],
631                    "scopes": [{ "pattern": "bucket:{bucket}" }]
632                }
633            ]
634        });
635        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
636            .expect("test client");
637        client.create_synced_tables().unwrap();
638
639        // A primary key that is also a scope column still needs a local base.
640        client
641            .conn
642            .execute(
643                "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
644                [],
645            )
646            .unwrap();
647        client
648            .patch(
649                "tenants",
650                "t1",
651                Map::from_iter([
652                    ("tenant_id".to_owned(), json!("t1")),
653                    ("body".to_owned(), json!("created")),
654                ]),
655                None,
656            )
657            .expect("the primary-key scope column is proven equal by construction");
658
659        // A stored REAL scope value coerces from a supplied integer.
660        client
661            .conn
662            .execute(
663                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
664                [],
665            )
666            .unwrap();
667        client
668            .patch(
669                "buckets",
670                "b1",
671                Map::from_iter([
672                    ("bucket".to_owned(), json!(2)),
673                    ("body".to_owned(), json!("coerced")),
674                ]),
675                None,
676            )
677            .expect("the stored REAL value coerces from a supplied integer");
678        let values = client.outbox[1].ops[0]
679            .values
680            .clone()
681            .expect("the patch records values");
682        assert!(!values.contains_key("bucket"));
683
684        // A differing value stays rejected.
685        let differing = client.patch(
686            "buckets",
687            "b1",
688            Map::from_iter([("bucket".to_owned(), json!(3))]),
689            None,
690        );
691        let differing = differing.expect_err("a differing scope value is rejected");
692        assert_eq!(differing.code, "sync.invalid_request");
693        assert_eq!(differing.message, "the authoring request is invalid");
694        assert!(
695            differing
696                .details
697                .as_ref()
698                .and_then(|details| details["legacyCause"].as_str())
699                .is_some_and(|cause| cause.contains("patch cannot write scope column")),
700            "{differing:?}"
701        );
702    }
703
704    #[cfg(feature = "e2ee")]
705    #[test]
706    fn stored_key_fallback_resolves_an_integer_primary_key() {
707        let schema = json!({
708            "version": 1,
709            "tables": [{
710                "name": "patients",
711                "primaryKey": "id",
712                "columns": [
713                    { "name": "id", "type": "integer", "nullable": false },
714                    { "name": "encryption_key_id", "type": "string", "nullable": true },
715                    { "name": "note", "type": "bytes", "nullable": true,
716                      "encrypted": true, "declaredType": "string" }
717                ],
718                "scopes": []
719            }]
720        });
721        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
722            .expect("test client");
723        client.create_synced_tables().unwrap();
724        client
725            .conn
726            .execute(
727                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
728                 VALUES (5, 'k1', NULL, 1)",
729                [],
730            )
731            .unwrap();
732        let mut config = crate::values::EncryptionConfig::default();
733        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
734        config
735            .key_id_columns
736            .insert("patients".to_owned(), "encryption_key_id".to_owned());
737        client.set_encryption(config);
738        let table = client.schema.table("patients").unwrap();
739        let fallback = client
740            .stored_key_fallback(table, "5")
741            .expect("the stored integer key resolves the fallback");
742        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
743    }
744
745    #[test]
746    fn patch_types_an_integer_primary_key_from_its_canonical_row_id() {
747        let schema = json!({
748            "version": 1,
749            "tables": [{
750                "name": "counters",
751                "primaryKey": "id",
752                "columns": [
753                    { "name": "id", "type": "integer", "nullable": false },
754                    { "name": "title", "type": "string", "nullable": false }
755                ],
756                "scopes": []
757            }]
758        });
759        let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
760            .expect("test client");
761        client
762            .write_base_row(
763                "counters",
764                &vec![
765                    Some(ColumnValue::Integer(42)),
766                    Some(ColumnValue::String("before".into())),
767                ],
768                1,
769            )
770            .unwrap();
771        client.rebuild_overlay().expect("rebuild overlay");
772        client
773            .patch(
774                "counters",
775                "42",
776                Map::from_iter([
777                    ("id".to_owned(), json!(42)),
778                    ("title".to_owned(), json!("after")),
779                ]),
780                None,
781            )
782            .expect("an integer key that renders as the rowId is not a change");
783        let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
784        assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
785        for row_id in ["042", "+42", "42.0"] {
786            let error = client
787                .patch(
788                    "counters",
789                    row_id,
790                    Map::from_iter([("title".to_owned(), json!("x"))]),
791                    None,
792                )
793                .expect_err("a non-canonical integer rowId names no row");
794            assert_eq!(error.code, "sync.row_missing", "{row_id}");
795        }
796    }
797
798    #[test]
799    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
800        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
801            {"name":"id","type":"string","nullable":false},
802            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
803        for state in ["absent", "cached", "pinned"] {
804            let path = std::env::temp_dir().join(format!(
805                "syncular-blob-stage-{}.sqlite",
806                uuid::Uuid::new_v4()
807            ));
808            let mut client = SyncClient::open_path(
809                "stage-test".into(),
810                &schema,
811                Default::default(),
812                path.to_str().unwrap(),
813            )
814            .unwrap();
815            let bytes = b"atomic stage";
816            if state != "absent" {
817                client
818                    .upload_blob(bytes, Some("text/plain".into()), None)
819                    .unwrap();
820                if state == "cached" {
821                    client
822                        .conn
823                        .execute("DELETE FROM _syncular_blob_uploads", [])
824                        .unwrap();
825                }
826            }
827            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
828            client
829                .conn
830                .execute_batch(
831                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
832                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
833                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
834                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
835                )
836                .unwrap();
837            let error = client
838                .upload_blob(bytes, Some("application/octet-stream".into()), None)
839                .unwrap_err();
840            assert!(error.contains("injected stage failure"), "{state}: {error}");
841            assert!(client.conn.is_autocommit());
842            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
843            drop(client);
844            let mut reopened = SyncClient::open_path(
845                "stage-test".into(),
846                &schema,
847                Default::default(),
848                path.to_str().unwrap(),
849            )
850            .unwrap();
851            let durable = reopened
852                .query("SELECT * FROM _syncular_blobs", &[])
853                .unwrap();
854            reopened
855                .conn
856                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
857                .unwrap();
858            let retried = reopened.upload_blob(bytes, None, None).unwrap();
859            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
860            assert_eq!(
861                reopened
862                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
863                    .unwrap(),
864                vec![Map::from_iter([(
865                    "blob_id".into(),
866                    Value::from(blob_id_for(bytes)),
867                )])]
868            );
869            drop(reopened);
870            std::fs::remove_file(&path).unwrap();
871            assert_eq!(durable, bodies, "{state}: reopened body state");
872            assert_eq!(immediate, bodies, "{state}: immediate body state");
873        }
874    }
875
876    #[test]
877    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
878        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
879            {"name":"id","type":"string","nullable":false},
880            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
881        for fault in [
882            "missing",
883            "body-type",
884            "length",
885            "hash",
886            "pin-metadata",
887            "read",
888            "pin-delete",
889        ] {
890            let path = std::env::temp_dir().join(format!(
891                "syncular-blob-upload-{}.sqlite",
892                uuid::Uuid::new_v4()
893            ));
894            let mut client = SyncClient::open_path(
895                "upload-test".into(),
896                &schema,
897                Default::default(),
898                path.to_str().unwrap(),
899            )
900            .unwrap();
901            let bytes = b"durable upload";
902            let ref_value = client
903                .upload_blob(bytes, Some("text/plain".into()), None)
904                .unwrap();
905            let commit = client
906                .mutate(vec![Mutation::Upsert {
907                    table: "attachments".into(),
908                    values: Map::from_iter([
909                        ("id".into(), Value::from("pending")),
910                        (
911                            "file".into(),
912                            Value::from(format!(
913                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
914                                blob_id_for(bytes),
915                                bytes.len()
916                            )),
917                        ),
918                    ]),
919                    base_version: None,
920                }])
921                .unwrap();
922            client
923                .conn
924                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
925                .unwrap();
926            client.conn.execute_batch(match fault {
927                "missing" => "DELETE FROM _syncular_blobs",
928                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
929                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
930                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
931                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
932                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
933                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
934            }).unwrap();
935            let state = if fault == "read" {
936                Vec::new()
937            } else {
938                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
939            };
940            client.set_meta(LOG_EPOCH_KEY, "epoch");
941            let mut transport = CountingRealtimeTransport::default();
942            let error = flush_blob_uploads(&mut client, &mut transport).unwrap_err();
943            assert_eq!(
944                error.code,
945                if matches!(fault, "read" | "pin-delete") {
946                    "client.failed"
947                } else {
948                    "sync.local_corrupt"
949                },
950                "{fault}"
951            );
952            assert_eq!(
953                transport.blob_uploads.len(),
954                usize::from(fault == "pin-delete"),
955                "{fault}: corrupt bytes must never be uploaded"
956            );
957            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
958            if fault != "read" {
959                assert_eq!(
960                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
961                    state
962                );
963            } else {
964                client
965                    .conn
966                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
967                    .unwrap();
968            }
969            drop(client);
970            let mut reopened = SyncClient::open_path(
971                "upload-test".into(),
972                &schema,
973                Default::default(),
974                path.to_str().unwrap(),
975            )
976            .unwrap();
977            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
978            if fault != "read" {
979                assert_eq!(
980                    reopened
981                        .query("SELECT * FROM _syncular_blobs", &[])
982                        .unwrap(),
983                    state
984                );
985            }
986            if fault == "pin-delete" {
987                reopened
988                    .conn
989                    .execute_batch("DROP TRIGGER fail_pin_delete")
990                    .unwrap();
991            }
992            if fault == "pin-metadata" {
993                reopened
994                    .conn
995                    .execute(
996                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
997                        [],
998                    )
999                    .unwrap();
1000            }
1001            reopened
1002                .conn
1003                .execute_batch(
1004                    "DELETE FROM _syncular_blobs;
1005                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
1006                )
1007                .unwrap();
1008            transport.blob_uploads.clear();
1009            flush_blob_uploads(&mut reopened, &mut transport).unwrap();
1010            assert_eq!(
1011                transport.blob_uploads,
1012                [(blob_id_for(bytes), bytes.to_vec())]
1013            );
1014            assert_eq!(
1015                reopened
1016                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
1017                    .unwrap(),
1018                Vec::<Map<String, Value>>::new()
1019            );
1020            assert_eq!(reopened.pending_commit_ids(), [commit]);
1021            assert_eq!(
1022                reopened
1023                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
1024                    .unwrap()
1025                    .unwrap()
1026                    .bytes,
1027                bytes
1028            );
1029            drop(reopened);
1030            std::fs::remove_file(path).unwrap();
1031        }
1032    }
1033
1034    /// §1.4 rule 4: `sections` subscriptions, each with `commits` COMMIT
1035    /// frames of `rows` rows. Returns the client with its subscriptions
1036    /// registered and the response.
1037    fn coalesced_fixture(sections: usize, commits: usize, rows: usize) -> (SyncClient, Message) {
1038        let mut client = client();
1039        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1040        let mut response = Message {
1041            wire_version: WIRE_VERSION,
1042            msg_kind: MsgKind::Response,
1043            frames: vec![Frame::RespHeader {
1044                required_schema_version: None,
1045                latest_schema_version: None,
1046                log_epoch: Some("epoch-1".into()),
1047                reset_required: Some(false),
1048            }],
1049        };
1050        let mut seq = 0;
1051        for section in 0..sections {
1052            let project = format!("p{section}");
1053            let scopes = vec![("project_id".to_owned(), vec![project.clone()])];
1054            client
1055                .subscribe(format!("s{section}"), "tasks".into(), scopes.clone(), None)
1056                .unwrap();
1057            let sub = client.subs.last_mut().unwrap();
1058            sub.cursor = 0;
1059            sub.synced_once = true;
1060            sub.effective = Some(scopes.clone());
1061            let sub = sub.clone();
1062            client.persist_sub(&sub).unwrap();
1063            response.frames.push(Frame::SubStart {
1064                id: format!("s{section}"),
1065                status: SubStatus::Active,
1066                reason_code: "".into(),
1067                effective_scopes: scopes,
1068                bootstrap: false,
1069            });
1070            for _ in 0..commits {
1071                seq += 1;
1072                let changes = (0..rows)
1073                    .map(|row| {
1074                        let id = format!("{project}-{seq}-{row}");
1075                        let values = Map::from_iter([
1076                            ("id".into(), Value::from(id.clone())),
1077                            ("project_id".into(), Value::from(project.clone())),
1078                        ]);
1079                        ssp2::model::Change {
1080                            table_index: 0,
1081                            row_id: id.clone(),
1082                            op: Op::Upsert,
1083                            row_version: Some(seq),
1084                            scopes: vec![("project_id".into(), project.clone())],
1085                            row: Some(
1086                                encode_row_json(
1087                                    client.schema.table("tasks").unwrap(),
1088                                    &id,
1089                                    &values,
1090                                    &client.encryption,
1091                                )
1092                                .unwrap(),
1093                            ),
1094                        }
1095                    })
1096                    .collect();
1097                response.frames.push(Frame::Commit {
1098                    commit_seq: seq,
1099                    actor_id: "writer".into(),
1100                    created_at_ms: 1,
1101                    tables: vec!["tasks".into()],
1102                    changes,
1103                });
1104            }
1105            response.frames.push(Frame::SubEnd {
1106                next_cursor: seq,
1107                bootstrap_state: None,
1108            });
1109        }
1110        client.drain_change_batches();
1111        (client, response)
1112    }
1113
1114    fn count_commits(client: &SyncClient) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
1115        let commits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1116        let counter = commits.clone();
1117        client
1118            .conn
1119            .commit_hook(Some(move || {
1120                counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1121                false
1122            }))
1123            .expect("commit hook");
1124        commits
1125    }
1126
1127    #[test]
1128    fn a_response_of_many_small_sections_commits_once() {
1129        for realtime in [false, true] {
1130            let (mut client, response) = coalesced_fixture(70, 1, 1);
1131            client.realtime_state = RealtimeState::Connected;
1132            client.realtime_reason_code = None;
1133            let mut transport = CountingRealtimeTransport::default();
1134            let (_, meta) = client.build_request(false).unwrap();
1135            let commits = count_commits(&client);
1136            if realtime {
1137                client.on_realtime_binary(&mut transport, &encode_message(&response));
1138            } else {
1139                assert!(matches!(
1140                    client.process_response(&mut transport, response, &meta),
1141                    SyncOutcome::Ok(_)
1142                ));
1143            }
1144            assert_eq!(
1145                commits.load(std::sync::atomic::Ordering::SeqCst),
1146                1,
1147                "{realtime}"
1148            );
1149            assert!(client.conn.is_autocommit());
1150            assert!(client.subs.iter().all(|sub| sub.cursor > 0));
1151            // Every frame keeps its own revision, published in order.
1152            let revisions = client
1153                .drain_change_batches()
1154                .into_iter()
1155                .filter(|batch| !batch.tables.is_empty())
1156                .map(|batch| batch.revision)
1157                .collect::<Vec<_>>();
1158            assert_eq!(
1159                revisions,
1160                (1..=70).map(|r: u64| r.to_string()).collect::<Vec<_>>()
1161            );
1162            assert_eq!(client.query("SELECT id FROM tasks", &[]).unwrap().len(), 70);
1163            // The ack follows the COMMIT.
1164            assert_eq!(
1165                transport
1166                    .messages
1167                    .iter()
1168                    .filter(|m| m.contains("\"ack\""))
1169                    .count(),
1170                1,
1171                "{realtime}"
1172            );
1173        }
1174    }
1175
1176    #[test]
1177    fn a_response_past_the_row_bound_commits_per_bound() {
1178        // 3 frames of 600 rows: no two fit one 1,024-row transaction.
1179        let (mut client, response) = coalesced_fixture(1, 3, 600);
1180        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1181        let (_, meta) = client.build_request(false).unwrap();
1182        let commits = count_commits(&client);
1183        assert!(matches!(
1184            client.process_response(&mut transport, response, &meta),
1185            SyncOutcome::Ok(_)
1186        ));
1187        assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 3);
1188        assert_eq!(
1189            client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1190            1800
1191        );
1192        assert_eq!(client.subs[0].cursor, 3);
1193    }
1194
1195    #[test]
1196    fn a_failed_coalesced_commit_restores_memory_and_publishes_nothing() {
1197        // Two sections of two frames of 600 rows: the bound commits the first
1198        // frame alone, so the deferred violation it carries fails that
1199        // mid-response COMMIT, and every later frame of the response fails.
1200        let (mut client, response) = coalesced_fixture(2, 2, 600);
1201        client
1202            .conn
1203            .execute_batch(
1204                "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",
1205            )
1206            .unwrap();
1207        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1208        let (_, meta) = client.build_request(false).unwrap();
1209        let subs = client.subs.clone();
1210        let revision = client.local_revision();
1211        let outcome = client.process_response(&mut transport, response.clone(), &meta);
1212        assert!(
1213            matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == "storage.failed"),
1214            "{outcome:?}"
1215        );
1216        assert!(client.conn.is_autocommit());
1217        assert!(client.coalesced.is_none() && client.coalesced_failure.is_none());
1218        assert_eq!(client.local_revision(), revision);
1219        assert!(client
1220            .drain_change_batches()
1221            .iter()
1222            .all(|batch| batch.tables.is_empty()));
1223        assert!(client
1224            .query("SELECT id FROM tasks", &[])
1225            .unwrap()
1226            .is_empty());
1227        assert_eq!(
1228            client.subs.iter().map(|s| s.cursor).collect::<Vec<_>>(),
1229            subs.iter().map(|s| s.cursor).collect::<Vec<_>>()
1230        );
1231        // The next response applies in full.
1232        client
1233            .conn
1234            .execute_batch("DROP TRIGGER fail_commit")
1235            .unwrap();
1236        let (_, meta) = client.build_request(false).unwrap();
1237        assert!(matches!(
1238            client.process_response(&mut transport, response, &meta),
1239            SyncOutcome::Ok(_)
1240        ));
1241        assert_eq!(
1242            client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1243            2400
1244        );
1245    }
1246
1247    #[test]
1248    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
1249        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1250            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
1251            "scopes":[{"pattern":"project:{project_id}"}]}]});
1252        for realtime in [false, true] {
1253            for fault in [
1254                "none",
1255                "row",
1256                "revision",
1257                "commit",
1258                "cursor",
1259                "error",
1260                "missing-end",
1261            ] {
1262                if realtime && fault == "missing-end" {
1263                    continue;
1264                }
1265                let path = std::env::temp_dir()
1266                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
1267                let mut client = SyncClient::open_path(
1268                    "frame-test".into(),
1269                    &schema,
1270                    Default::default(),
1271                    path.to_str().unwrap(),
1272                )
1273                .unwrap();
1274                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1275                client
1276                    .subscribe(
1277                        "tasks".into(),
1278                        "tasks".into(),
1279                        vec![("project_id".into(), vec!["p1".into()])],
1280                        None,
1281                    )
1282                    .unwrap();
1283                client.subs[0].cursor = 0;
1284                client.subs[0].synced_once = true;
1285                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1286                client.persist_sub(&client.subs[0]).unwrap();
1287                client.realtime_state = RealtimeState::Connected;
1288                client.realtime_reason_code = None;
1289                client.drain_change_batches();
1290                let mut transport = CountingRealtimeTransport::default();
1291                let (_, meta) = client.build_request(false).unwrap();
1292                let mut response = Message {
1293                    wire_version: WIRE_VERSION,
1294                    msg_kind: MsgKind::Response,
1295                    frames: vec![
1296                        Frame::RespHeader {
1297                            required_schema_version: None,
1298                            latest_schema_version: None,
1299                            log_epoch: Some("epoch-1".into()),
1300                            reset_required: Some(false),
1301                        },
1302                        Frame::SubStart {
1303                            id: "tasks".into(),
1304                            status: SubStatus::Active,
1305                            reason_code: "".into(),
1306                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1307                            bootstrap: false,
1308                        },
1309                    ],
1310                };
1311                for (index, id) in ["first", "second"].iter().enumerate() {
1312                    let values = Map::from_iter([
1313                        ("id".into(), Value::from(*id)),
1314                        ("project_id".into(), Value::from("p1")),
1315                    ]);
1316                    response.frames.push(Frame::Commit {
1317                        commit_seq: index as i64 + 1,
1318                        actor_id: "writer".into(),
1319                        created_at_ms: 1,
1320                        tables: vec!["tasks".into()],
1321                        changes: vec![ssp2::model::Change {
1322                            table_index: 0,
1323                            row_id: (*id).into(),
1324                            op: Op::Upsert,
1325                            row_version: Some(index as i64 + 1),
1326                            scopes: vec![("project_id".into(), "p1".into())],
1327                            row: Some(
1328                                encode_row_json(
1329                                    client.schema.table("tasks").unwrap(),
1330                                    id,
1331                                    &values,
1332                                    &client.encryption,
1333                                )
1334                                .unwrap(),
1335                            ),
1336                        }],
1337                    });
1338                }
1339                response.frames.push(Frame::SubEnd {
1340                    next_cursor: 2,
1341                    bootstrap_state: None,
1342                });
1343                let valid = response.clone();
1344                match fault {
1345                    "row" => {
1346                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
1347                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
1348                    },
1349                    "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(),
1350                    "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(),
1351                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
1352                    "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});},
1353                    "missing-end" => {response.frames.truncate(3);},
1354                    _ => {}
1355                }
1356                if realtime {
1357                    client.on_realtime_binary(&mut transport, &encode_message(&response));
1358                } else {
1359                    assert_eq!(
1360                        matches!(
1361                            client.process_response(&mut transport, response, &meta),
1362                            SyncOutcome::Ok(_)
1363                        ),
1364                        fault == "none",
1365                        "{fault}"
1366                    );
1367                }
1368                assert!(
1369                    client.conn.is_autocommit(),
1370                    "no open transaction after {realtime}/{fault}"
1371                );
1372                // §1.4 rule 4: the deferred foreign key of the "commit" fault
1373                // fails the response's coalesced COMMIT, which rolls back both
1374                // frames; the other faults roll back only the failing frame.
1375                let count = match fault {
1376                    "none" | "cursor" => 2,
1377                    "commit" => 0,
1378                    _ => 1,
1379                };
1380                let expected_revision = count + u64::from(realtime && fault != "none");
1381                assert_eq!(
1382                    client.local_revision(),
1383                    expected_revision,
1384                    "{realtime}/{fault}"
1385                );
1386                let batches = client.drain_change_batches();
1387                assert_eq!(
1388                    batches.len(),
1389                    expected_revision as usize,
1390                    "{realtime}/{fault}"
1391                );
1392                let row_batches: Vec<_> = batches
1393                    .iter()
1394                    .filter(|batch| !batch.tables.is_empty())
1395                    .collect();
1396                assert_eq!(row_batches.len(), count as usize);
1397                assert_eq!(
1398                    row_batches
1399                        .iter()
1400                        .map(|b| b.revision.clone())
1401                        .collect::<Vec<_>>(),
1402                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1403                );
1404                for table in ["tasks", "_syncular_base_tasks"] {
1405                    assert_eq!(
1406                        client
1407                            .conn
1408                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1409                                .get::<_, u64>(0))
1410                            .unwrap(),
1411                        count,
1412                        "{realtime}/{fault}/{table}"
1413                    );
1414                }
1415                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1416                assert_eq!(
1417                    transport
1418                        .messages
1419                        .iter()
1420                        .filter(|m| m.contains("\"ack\""))
1421                        .count(),
1422                    usize::from(fault == "none")
1423                );
1424                if matches!(fault, "revision" | "commit" | "cursor") {
1425                    client
1426                        .conn
1427                        .execute_batch("DROP TRIGGER fail_frame")
1428                        .unwrap();
1429                }
1430                drop(client);
1431                let mut reopened = SyncClient::open_path(
1432                    "frame-test".into(),
1433                    &schema,
1434                    Default::default(),
1435                    path.to_str().unwrap(),
1436                )
1437                .unwrap();
1438                assert_eq!(reopened.local_revision(), expected_revision);
1439                assert_eq!(
1440                    reopened
1441                        .query("SELECT id FROM tasks ORDER BY id", &[])
1442                        .unwrap()
1443                        .len(),
1444                    count as usize
1445                );
1446                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1447                let (_, meta) = reopened.build_request(false).unwrap();
1448                assert!(matches!(
1449                    reopened.process_response(&mut transport, valid, &meta),
1450                    SyncOutcome::Ok(_)
1451                ));
1452                assert_eq!(
1453                    reopened
1454                        .query("SELECT id FROM tasks ORDER BY id", &[])
1455                        .unwrap()
1456                        .len(),
1457                    2
1458                );
1459                assert_eq!(reopened.subs[0].cursor, 2);
1460                drop(reopened);
1461                std::fs::remove_file(path).unwrap();
1462            }
1463        }
1464    }
1465
1466    #[test]
1467    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1468        for status in [SubStatus::Reset, SubStatus::Revoked] {
1469            for fault in ["subscription", "revision"] {
1470                let mut client = client();
1471                client
1472                    .subscribe(
1473                        "tasks".into(),
1474                        "tasks".into(),
1475                        vec![("project_id".into(), vec!["p1".into()])],
1476                        None,
1477                    )
1478                    .unwrap();
1479                client.subs[0].cursor = 41;
1480                client.subs[0].synced_once = true;
1481                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1482                client.persist_sub(&client.subs[0]).unwrap();
1483                client
1484                    .mutate(vec![Mutation::Upsert {
1485                        table: "tasks".into(),
1486                        values: Map::from_iter([
1487                            ("id".into(), Value::from("pending")),
1488                            ("project_id".into(), Value::from("p1")),
1489                        ]),
1490                        base_version: None,
1491                    }])
1492                    .unwrap();
1493                let ids = client.pending_commit_ids();
1494                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1495                let revision = client.local_revision();
1496                client.drain_change_batches();
1497                let (_, meta) = client.build_request(false).unwrap();
1498                client.conn.execute_batch(if fault == "subscription" {
1499                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1500                } else {
1501                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1502                }).unwrap();
1503                let mut transport = CountingRealtimeTransport::default();
1504                let mut report = SyncReport::default();
1505                // Reset changes window completeness and therefore needs a revision.
1506                if status == SubStatus::Reset {
1507                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1508                }
1509                let result = client.process_section(
1510                    &mut transport,
1511                    "tasks",
1512                    status,
1513                    "sync.scope_revoked",
1514                    vec![],
1515                    vec![],
1516                    Some((42, None)),
1517                    &meta,
1518                    &mut report,
1519                    &HashSet::new(),
1520                );
1521                assert!(result.is_err(), "{status:?}/{fault}");
1522                assert!(client.conn.is_autocommit());
1523                assert_eq!(client.subs[0].cursor, 41);
1524                assert_eq!(client.subs[0].state, SubState::Active);
1525                assert_eq!(client.pending_commit_ids(), ids);
1526                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1527                assert!(client.rejections.is_empty());
1528                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1529                assert_eq!(client.local_revision(), revision);
1530                assert!(client.drain_change_batches().is_empty());
1531                assert!(report.revoked.is_empty() && report.resets.is_empty());
1532            }
1533        }
1534    }
1535
1536    #[test]
1537    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1538        let mut client = client();
1539        client
1540            .subscribe(
1541                "tasks".into(),
1542                "tasks".into(),
1543                vec![("project_id".into(), vec!["p1".into()])],
1544                None,
1545            )
1546            .unwrap();
1547        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1548        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1549            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1550        let row = |id: &str| ssp2::segment::SegmentRow {
1551            server_version: 1,
1552            values: vec![
1553                Some(ColumnValue::String(id.into())),
1554                Some(ColumnValue::String("p1".into())),
1555            ],
1556        };
1557        let segment = RowsSegment {
1558            table: "tasks".into(),
1559            schema_version: 1,
1560            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1561            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1562        };
1563        assert!(client.apply_segment(0, &segment, true).is_err());
1564        assert!(client.conn.is_autocommit());
1565        assert_eq!(
1566            client
1567                .query("SELECT id FROM tasks ORDER BY id", &[])
1568                .unwrap(),
1569            vec![Map::from_iter([("id".into(), Value::from("first"))])]
1570        );
1571        assert_eq!(client.local_revision(), 1);
1572        assert_eq!(client.drain_change_batches().len(), 1);
1573        assert_eq!(client.subs[0].cursor, -1);
1574        client
1575            .conn
1576            .execute_batch("DROP TRIGGER fail_block")
1577            .unwrap();
1578        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1579        assert_eq!(client.local_revision(), 3);
1580        assert_eq!(client.drain_change_batches().len(), 2);
1581    }
1582
1583    /// Serves one response and records the segment batch it causes. A single
1584    /// `download_segment` call means the core fell back to per-descriptor
1585    /// fetches.
1586    struct SegmentBatchTransport {
1587        response: Message,
1588        bodies: BTreeMap<String, Vec<u8>>,
1589        batches: Vec<Vec<String>>,
1590        single_fetches: usize,
1591    }
1592
1593    impl Transport for SegmentBatchTransport {
1594        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1595            Ok(encode_message(&self.response))
1596        }
1597
1598        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1599            self.sync(request)
1600        }
1601
1602        fn download_segment(
1603            &mut self,
1604            _request: &SegmentRequest,
1605            _on_progress: &mut dyn FnMut(u64),
1606        ) -> Result<Vec<u8>, TransportError> {
1607            self.single_fetches += 1;
1608            Err(TransportError::new(
1609                "sync.transport_failed",
1610                "per-descriptor fetch",
1611            ))
1612        }
1613
1614        fn download_segments(
1615            &mut self,
1616            fetches: &[crate::SegmentFetch],
1617            on_progress: &mut (dyn FnMut(usize, u64) + Send),
1618        ) -> Vec<Result<Vec<u8>, TransportError>> {
1619            self.batches.push(
1620                fetches
1621                    .iter()
1622                    .map(|fetch| match fetch {
1623                        crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
1624                        crate::SegmentFetch::Url(url) => url.clone(),
1625                    })
1626                    .collect(),
1627            );
1628            fetches
1629                .iter()
1630                .enumerate()
1631                .map(|(index, fetch)| {
1632                    let crate::SegmentFetch::Direct(request) = fetch else {
1633                        panic!("the descriptors are direct-endpoint requests")
1634                    };
1635                    let bytes = self.bodies[&request.segment_id].clone();
1636                    on_progress(index, bytes.len() as u64);
1637                    Ok(bytes)
1638                })
1639                .collect()
1640        }
1641
1642        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1643            Ok(())
1644        }
1645
1646        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1647            Ok(())
1648        }
1649
1650        fn realtime_close(&mut self) -> Result<(), TransportError> {
1651            Ok(())
1652        }
1653    }
1654
1655    #[test]
1656    fn a_response_fetches_all_its_segment_pages_in_one_ordered_batch() {
1657        let mut client = client();
1658        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1659        client
1660            .subscribe(
1661                "tasks".into(),
1662                "tasks".into(),
1663                vec![("project_id".into(), vec!["p1".into()])],
1664                None,
1665            )
1666            .unwrap();
1667        client.subs[0].cursor = 0;
1668        client.subs[0].synced_once = true;
1669        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1670        client.persist_sub(&client.subs[0]).unwrap();
1671        client.drain_change_batches();
1672        let columns = client.schema.table("tasks").unwrap().wire_columns.clone();
1673
1674        // Four pages, one row each. Applying a page under another page's
1675        // descriptor fails §5.1 content-address verification, so a green round
1676        // proves the results kept the wire order.
1677        let pages = ["one", "two", "three", "four"].map(|id| {
1678            let bytes = ssp2::segment::encode_rows_segment(&RowsSegment {
1679                table: "tasks".into(),
1680                schema_version: 1,
1681                columns: columns.clone(),
1682                blocks: vec![vec![ssp2::segment::SegmentRow {
1683                    server_version: 1,
1684                    values: vec![
1685                        Some(ColumnValue::String(id.into())),
1686                        Some(ColumnValue::String("p1".into())),
1687                    ],
1688                }]],
1689            });
1690            (
1691                format!("sha256:{}", bytes_to_hex(&Sha256::digest(&bytes))),
1692                bytes,
1693            )
1694        });
1695
1696        let mut frames = vec![
1697            Frame::RespHeader {
1698                required_schema_version: None,
1699                latest_schema_version: None,
1700                log_epoch: Some("epoch-1".into()),
1701                reset_required: Some(false),
1702            },
1703            Frame::SubStart {
1704                id: "tasks".into(),
1705                status: SubStatus::Active,
1706                reason_code: String::new(),
1707                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1708                bootstrap: false,
1709            },
1710        ];
1711        for (segment_id, bytes) in &pages {
1712            frames.push(Frame::SegmentRef {
1713                segment_id: segment_id.clone(),
1714                media_type: MediaType::Rows,
1715                table: "tasks".into(),
1716                byte_length: bytes.len() as i64,
1717                row_count: 1,
1718                as_of_commit_seq: 1,
1719                scope_digest: "digest".into(),
1720                row_cursor: None,
1721                next_row_cursor: None,
1722                url: None,
1723                url_expires_at_ms: None,
1724            });
1725        }
1726        frames.push(Frame::SubEnd {
1727            next_cursor: 1,
1728            bootstrap_state: None,
1729        });
1730        let mut transport = SegmentBatchTransport {
1731            response: Message {
1732                wire_version: WIRE_VERSION,
1733                msg_kind: MsgKind::Response,
1734                frames,
1735            },
1736            bodies: pages
1737                .iter()
1738                .map(|(segment_id, bytes)| (segment_id.clone(), bytes.clone()))
1739                .collect(),
1740            batches: Vec::new(),
1741            single_fetches: 0,
1742        };
1743
1744        let outcome = client.sync(&mut transport);
1745        assert!(matches!(outcome, SyncOutcome::Ok(_)), "{outcome:?}");
1746        assert_eq!(
1747            transport.batches,
1748            vec![pages
1749                .iter()
1750                .map(|(segment_id, _)| segment_id.clone())
1751                .collect::<Vec<_>>()],
1752            "one batch per response, in wire order"
1753        );
1754        assert_eq!(
1755            transport.single_fetches, 0,
1756            "the batch seam replaces the per-descriptor calls"
1757        );
1758        assert_eq!(
1759            client
1760                .query("SELECT id FROM tasks ORDER BY id", &[])
1761                .unwrap(),
1762            ["four", "one", "three", "two"]
1763                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
1764                .to_vec()
1765        );
1766        assert_eq!(client.subs[0].cursor, 1);
1767    }
1768
1769    #[test]
1770    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1771        assert_eq!(
1772            client().diagnostics_storage().blob_cache_bytes_approx,
1773            Some(0)
1774        );
1775        let client = SyncClient::new(
1776            "diagnostic-storage".to_owned(),
1777            &json!({
1778                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1779                    "columns": [{"name": "id", "type": "string", "nullable": false},
1780                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1781            }),
1782            ClientLimits {
1783                blob_cache_max_bytes: Some(5),
1784                ..Default::default()
1785            },
1786        )
1787        .unwrap();
1788        let empty = client.diagnostics_storage();
1789        assert_eq!(empty.status, "healthy");
1790        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1791        assert_eq!(empty.retained_outcome_entries, Some(0));
1792        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1793        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1794        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1795            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1796                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1797            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1798                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1799        let written = client.diagnostics_storage();
1800        assert_eq!(written.status, "pressure");
1801        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1802        assert_eq!(written.retained_outcome_entries, Some(2));
1803        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1804        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1805        assert_eq!(
1806            written.pressure_reason_code.as_deref(),
1807            Some("client.blob_cache_over_limit")
1808        );
1809        client
1810            .conn
1811            .execute_batch(
1812                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1813            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1814            )
1815            .unwrap();
1816        let staged = client.diagnostics_storage();
1817        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1818        assert_eq!(staged.retained_outcome_entries, Some(0));
1819        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1820        assert_eq!(staged.status, "healthy");
1821        client.conn.execute_batch("ROLLBACK").unwrap();
1822        assert_eq!(client.diagnostics_storage(), written);
1823        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1824        let unreadable = client.diagnostics_storage();
1825        assert_eq!(unreadable.status, "unreadable");
1826        assert!(unreadable.database_bytes_approx.is_none());
1827        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1828        assert!(unreadable.retained_outcome_entries.is_none());
1829        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1830        assert!(unreadable.blob_cache_bytes_approx.is_none());
1831        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1832        let restored = client.diagnostics_storage();
1833        assert_eq!(restored.status, written.status);
1834        assert_eq!(
1835            restored.retained_outcome_bytes_approx,
1836            written.retained_outcome_bytes_approx
1837        );
1838        client.conn.set_prepared_statement_cache_capacity(0);
1839        assert_eq!(client.diagnostics_storage(), restored);
1840    }
1841
1842    /// Answers every round with one server `ERROR` frame (§1.6).
1843    struct ServerErrorTransport {
1844        code: &'static str,
1845        retryable: bool,
1846    }
1847
1848    impl Transport for ServerErrorTransport {
1849        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1850            Ok(encode_message(&Message {
1851                wire_version: WIRE_VERSION,
1852                msg_kind: MsgKind::Response,
1853                frames: vec![
1854                    Frame::RespHeader {
1855                        required_schema_version: None,
1856                        latest_schema_version: None,
1857                        log_epoch: Some("epoch-1".to_owned()),
1858                        reset_required: Some(false),
1859                    },
1860                    Frame::Error {
1861                        code: self.code.to_owned(),
1862                        message: "server error".to_owned(),
1863                        category: "internal".to_owned(),
1864                        retryable: self.retryable,
1865                        recommended_action: "retryLater".to_owned(),
1866                        details: None,
1867                    },
1868                ],
1869            }))
1870        }
1871
1872        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1873            self.sync(request)
1874        }
1875
1876        fn download_segment(
1877            &mut self,
1878            _request: &SegmentRequest,
1879            _on_progress: &mut dyn FnMut(u64),
1880        ) -> Result<Vec<u8>, TransportError> {
1881            Err(TransportError::new("sync.transport_failed", "offline"))
1882        }
1883
1884        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1885            Ok(())
1886        }
1887
1888        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1889            Ok(())
1890        }
1891
1892        fn realtime_close(&mut self) -> Result<(), TransportError> {
1893            Ok(())
1894        }
1895    }
1896
1897    #[test]
1898    fn a_retryable_in_band_failure_keeps_the_socket_connected_and_still_asks_for_a_retry() {
1899        let mut client = client();
1900        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1901        let mut transport = ServerErrorTransport {
1902            code: "sync.schema_not_ready",
1903            retryable: true,
1904        };
1905        client.connect_realtime(&mut transport).expect("connect");
1906        assert!(matches!(
1907            client.sync(&mut transport),
1908            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.schema_not_ready"
1909        ));
1910        assert_eq!(
1911            client.realtime_state(),
1912            RealtimeState::Connected,
1913            "the socket delivered the answer, so it is not lost"
1914        );
1915        assert!(
1916            matches!(
1917                client.drain_sync_intents().as_slice(),
1918                [SyncIntent::Background { delay_ms: 250 }]
1919            ),
1920            "the retry deadline is a sync round and no reconnect satisfies it"
1921        );
1922        assert_eq!(
1923            client.progress().snapshot().unwrap().retry_delay_ms,
1924            Some(250)
1925        );
1926    }
1927
1928    #[test]
1929    fn retryable_server_error_schedules_a_background_retry() {
1930        let mut client = client();
1931        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1932        let mut transport = ServerErrorTransport {
1933            code: "sync.internal_error",
1934            retryable: true,
1935        };
1936        assert!(matches!(
1937            client.sync(&mut transport),
1938            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1939        ));
1940        assert!(matches!(
1941            client.drain_sync_intents().as_slice(),
1942            [SyncIntent::Background { delay_ms: 250 }]
1943        ));
1944        assert_eq!(
1945            client.progress().snapshot().unwrap().retry_delay_ms,
1946            Some(250)
1947        );
1948        let mut transport = ServerErrorTransport {
1949            code: "sync.invalid_request",
1950            retryable: false,
1951        };
1952        assert!(matches!(
1953            client.sync(&mut transport),
1954            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1955        ));
1956        assert!(client.drain_sync_intents().is_empty());
1957        let progress = client.progress().snapshot().unwrap();
1958        assert_eq!(progress.state, ProgressState::Failed);
1959        assert_eq!(progress.retry_delay_ms, None);
1960    }
1961
1962    #[test]
1963    fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1964        // §7.7: the exhausted outcome is a partial success, not a failure,
1965        // and serializes with an explicit flag.
1966        let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1967        assert_eq!(exhausted["ok"], json!(true));
1968        assert_eq!(exhausted["budgetExhausted"], json!(true));
1969        assert!(exhausted["report"].is_object());
1970
1971        let mut client = client();
1972        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1973        // Zero explicit budget is a caller error, not an exhaustion.
1974        let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1975        let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1976        assert!(
1977            matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1978            "{zero:?}"
1979        );
1980        // A real server failure stays a failure across the budget loop.
1981        let mut erroring = ServerErrorTransport {
1982            code: "sync.internal_error",
1983            retryable: false,
1984        };
1985        let failed = client.sync_until_idle(&mut erroring, Some(3));
1986        assert!(
1987            matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1988            "{failed:?}"
1989        );
1990    }
1991
1992    /// A transport that fails every round with one `causeKind`.
1993    struct CauseTransport(&'static str);
1994
1995    impl Transport for CauseTransport {
1996        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1997            let mut error = TransportError::new("transport.failed", "no route");
1998            error.details = Some(serde_json::json!({ "causeKind": self.0 }));
1999            Err(error)
2000        }
2001
2002        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2003            self.sync(request)
2004        }
2005
2006        fn download_segment(
2007            &mut self,
2008            _request: &SegmentRequest,
2009            _on_progress: &mut dyn FnMut(u64),
2010        ) -> Result<Vec<u8>, TransportError> {
2011            Err(TransportError::new("sync.transport_failed", "offline"))
2012        }
2013
2014        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2015            Ok(())
2016        }
2017
2018        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2019            Ok(())
2020        }
2021
2022        fn realtime_close(&mut self) -> Result<(), TransportError> {
2023            Ok(())
2024        }
2025    }
2026
2027    #[test]
2028    fn an_unreachable_transport_schedules_no_retry() {
2029        // §1.8: `connect` (native) and `network` (browser fetch) describe a
2030        // refused or unreachable endpoint; neither earns another backoff.
2031        for cause in ["connect", "network"] {
2032            let mut client = client();
2033            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2034            let mut transport = CauseTransport(cause);
2035            assert!(matches!(
2036                client.sync(&mut transport),
2037                SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.failed"
2038            ));
2039            assert!(
2040                client.drain_sync_intents().is_empty(),
2041                "{cause} scheduled a retry"
2042            );
2043        }
2044
2045        // Every other retryable transport cause keeps the background retry.
2046        let mut client = client();
2047        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2048        let mut transport = CauseTransport("timeout");
2049        assert!(matches!(
2050            client.sync(&mut transport),
2051            SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.failed"
2052        ));
2053        assert!(matches!(
2054            client.drain_sync_intents().as_slice(),
2055            [SyncIntent::Background { delay_ms: 250 }]
2056        ));
2057    }
2058
2059    #[test]
2060    fn sync_until_idle_reports_its_total_deadline() {
2061        assert_eq!(SyncClient::SYNC_IDLE_TIMEOUT_MS, 60_000);
2062        let mut client = client();
2063        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2064        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2065
2066        // A zero deadline is a caller error, like a zero round budget.
2067        let invalid = client.sync_until_idle_with_deadline(&mut transport, None, Some(0));
2068        assert!(
2069            matches!(invalid, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
2070            "{invalid:?}"
2071        );
2072
2073        // The expired deadline stops before the first round and reports how
2074        // many rounds ran.
2075        let now = client.clock_now_ms();
2076        let outcome = client.sync_until_idle_bounded(&mut transport, None, now, Some(now));
2077        let SyncOutcome::Failed {
2078            error_code,
2079            details,
2080            recommended_action,
2081            ..
2082        } = outcome
2083        else {
2084            panic!("expected the idle deadline, got {outcome:?}");
2085        };
2086        assert_eq!(error_code, "sync.idle_timeout");
2087        assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2088        let details = details.expect("deadline details");
2089        assert_eq!(details["timeoutMs"], json!(0));
2090        assert_eq!(details["rounds"], json!(0));
2091    }
2092
2093    /// Returns one pre-built response per round so a test can pin a successful
2094    /// work-bearing round followed by a failure or a schema floor.
2095    struct ScriptedSyncTransport {
2096        responses: std::collections::VecDeque<Message>,
2097        calls: usize,
2098    }
2099
2100    impl Transport for ScriptedSyncTransport {
2101        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2102            self.calls += 1;
2103            let response = self.responses.pop_front().expect("scripted response");
2104            Ok(encode_message(&response))
2105        }
2106
2107        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2108            self.sync(request)
2109        }
2110
2111        fn download_segment(
2112            &mut self,
2113            _request: &SegmentRequest,
2114            _on_progress: &mut dyn FnMut(u64),
2115        ) -> Result<Vec<u8>, TransportError> {
2116            Err(TransportError::new("sync.transport_failed", "offline"))
2117        }
2118
2119        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2120            Ok(())
2121        }
2122
2123        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2124            Ok(())
2125        }
2126
2127        fn realtime_close(&mut self) -> Result<(), TransportError> {
2128            Ok(())
2129        }
2130    }
2131
2132    #[test]
2133    fn sync_until_idle_success_then_failure_keeps_earlier_work() {
2134        let path = std::env::temp_dir().join(format!(
2135            "syncular-budget-script-{}.sqlite",
2136            uuid::Uuid::new_v4()
2137        ));
2138        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2139            {"name":"id","type":"string","nullable":false},
2140            {"name":"project_id","type":"string","nullable":false}],
2141            "scopes":[{"pattern":"project:{project_id}"}]}]});
2142        let mut client = SyncClient::open_path(
2143            "budget-script".into(),
2144            &schema,
2145            ClientLimits::default(),
2146            path.to_str().unwrap(),
2147        )
2148        .expect("open client");
2149        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2150        client
2151            .subscribe(
2152                "tasks".into(),
2153                "tasks".into(),
2154                vec![("project_id".into(), vec!["p1".into()])],
2155                None,
2156            )
2157            .unwrap();
2158        client.subs[0].cursor = 0;
2159        client.subs[0].synced_once = true;
2160        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2161        client.persist_sub(&client.subs[0]).unwrap();
2162        client.drain_change_batches();
2163
2164        let row = encode_row_json(
2165            client.schema.table("tasks").unwrap(),
2166            "kept",
2167            &Map::from_iter([
2168                ("id".into(), json!("kept")),
2169                ("project_id".into(), json!("p1")),
2170            ]),
2171            &client.encryption,
2172        )
2173        .unwrap();
2174        let success = Message {
2175            wire_version: WIRE_VERSION,
2176            msg_kind: MsgKind::Response,
2177            frames: vec![
2178                Frame::RespHeader {
2179                    required_schema_version: None,
2180                    latest_schema_version: None,
2181                    log_epoch: Some("epoch-1".into()),
2182                    reset_required: Some(false),
2183                },
2184                Frame::SubStart {
2185                    id: "tasks".into(),
2186                    status: SubStatus::Active,
2187                    reason_code: String::new(),
2188                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2189                    bootstrap: false,
2190                },
2191                Frame::Commit {
2192                    commit_seq: 1,
2193                    created_at_ms: 1,
2194                    actor_id: "writer".into(),
2195                    tables: vec!["tasks".into()],
2196                    changes: vec![ssp2::model::Change {
2197                        table_index: 0,
2198                        row_id: "kept".into(),
2199                        op: Op::Upsert,
2200                        row_version: Some(1),
2201                        scopes: vec![("project_id".into(), "p1".into())],
2202                        row: Some(row),
2203                    }],
2204                },
2205                Frame::SubEnd {
2206                    next_cursor: 1,
2207                    bootstrap_state: None,
2208                },
2209            ],
2210        };
2211        let failure = Message {
2212            wire_version: WIRE_VERSION,
2213            msg_kind: MsgKind::Response,
2214            frames: vec![
2215                Frame::RespHeader {
2216                    required_schema_version: None,
2217                    latest_schema_version: None,
2218                    log_epoch: Some("epoch-1".into()),
2219                    reset_required: Some(false),
2220                },
2221                Frame::Error {
2222                    code: "sync.internal_error".into(),
2223                    message: "second round failed".into(),
2224                    category: "internal".into(),
2225                    retryable: false,
2226                    recommended_action: "retryLater".into(),
2227                    details: None,
2228                },
2229            ],
2230        };
2231        let mut transport = ScriptedSyncTransport {
2232            responses: std::collections::VecDeque::from([success, failure]),
2233            calls: 0,
2234        };
2235        let outcome = client.sync_until_idle(&mut transport, Some(2));
2236        let SyncOutcome::Failed {
2237            error_code,
2238            recommended_action,
2239            ..
2240        } = outcome
2241        else {
2242            panic!("expected the in-band failure");
2243        };
2244        assert_eq!(error_code, "sync.internal_error");
2245        assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2246        assert_eq!(
2247            transport.calls, 2,
2248            "one successful round then the failing round"
2249        );
2250        assert_eq!(
2251            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2252            json!("kept"),
2253            "the earlier round's durable work survives the later failure"
2254        );
2255        drop(client);
2256        let _ = std::fs::remove_file(&path);
2257    }
2258
2259    #[test]
2260    fn sync_until_idle_stops_on_a_schema_floor() {
2261        let mut client = client();
2262        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2263        let floor = Message {
2264            wire_version: WIRE_VERSION,
2265            msg_kind: MsgKind::Response,
2266            frames: vec![Frame::RespHeader {
2267                required_schema_version: Some(2),
2268                latest_schema_version: Some(2),
2269                log_epoch: Some("epoch-1".into()),
2270                reset_required: Some(false),
2271            }],
2272        };
2273        let mut transport = ScriptedSyncTransport {
2274            responses: std::collections::VecDeque::from([floor.clone(), floor]),
2275            calls: 0,
2276        };
2277        let outcome = client.sync_until_idle(&mut transport, Some(3));
2278        match outcome {
2279            SyncOutcome::Ok(report) => assert_eq!(
2280                report
2281                    .schema_floor
2282                    .as_ref()
2283                    .and_then(|floor| floor.required_schema_version),
2284                Some(2),
2285                "the stop retains the latest schema floor"
2286            ),
2287            other => panic!("expected a schema-floor stop, got {other:?}"),
2288        }
2289        assert_eq!(
2290            transport.calls, 1,
2291            "a schema floor stops the budget loop after one round"
2292        );
2293    }
2294
2295    /// Records the decoded request of every round and answers from a script of
2296    /// round results, so a test can pin what the following request carries.
2297    struct FailureScriptTransport {
2298        rounds: std::collections::VecDeque<Result<Vec<u8>, TransportError>>,
2299        requests: Vec<Message>,
2300    }
2301
2302    impl Transport for FailureScriptTransport {
2303        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2304            self.requests
2305                .push(decode_message(request).expect("the client encodes a legal request"));
2306            self.rounds.pop_front().expect("scripted round")
2307        }
2308
2309        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2310            self.sync(request)
2311        }
2312
2313        fn download_segment(
2314            &mut self,
2315            _request: &SegmentRequest,
2316            _on_progress: &mut dyn FnMut(u64),
2317        ) -> Result<Vec<u8>, TransportError> {
2318            Err(TransportError::new("transport.timeout", "segment stalled"))
2319        }
2320
2321        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2322            Ok(())
2323        }
2324
2325        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2326            Ok(())
2327        }
2328
2329        fn realtime_close(&mut self) -> Result<(), TransportError> {
2330            Ok(())
2331        }
2332    }
2333
2334    /// A complete response: RESP_HEADER for the client's epoch plus `frames`.
2335    fn completed_response(frames: Vec<Frame>) -> Vec<u8> {
2336        let mut all = vec![Frame::RespHeader {
2337            required_schema_version: None,
2338            latest_schema_version: None,
2339            log_epoch: Some("epoch-1".into()),
2340            reset_required: Some(false),
2341        }];
2342        all.extend(frames);
2343        encode_message(&Message {
2344            wire_version: WIRE_VERSION,
2345            msg_kind: MsgKind::Response,
2346            frames: all,
2347        })
2348    }
2349
2350    fn carried_round_failures(request: &Message) -> Vec<String> {
2351        request
2352            .frames
2353            .iter()
2354            .filter_map(|frame| match frame {
2355                Frame::RoundFailure { code } => Some(code.clone()),
2356                _ => None,
2357            })
2358            .collect()
2359    }
2360
2361    fn failure_transport(
2362        rounds: impl IntoIterator<Item = Result<Vec<u8>, TransportError>>,
2363    ) -> FailureScriptTransport {
2364        FailureScriptTransport {
2365            rounds: rounds.into_iter().collect(),
2366            requests: Vec::new(),
2367        }
2368    }
2369
2370    #[test]
2371    fn a_round_without_a_complete_response_reports_its_code_on_every_following_request() {
2372        let mut client = client();
2373        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2374        let mut transport = failure_transport([
2375            Err(TransportError::new("transport.timeout", "round timed out")),
2376            Ok(completed_response(Vec::new())),
2377            Ok(completed_response(Vec::new())),
2378        ]);
2379        for _ in 0..3 {
2380            client.sync(&mut transport);
2381        }
2382        assert!(carried_round_failures(&transport.requests[0]).is_empty());
2383        assert_eq!(
2384            carried_round_failures(&transport.requests[1]),
2385            vec!["transport.timeout"]
2386        );
2387        assert!(
2388            matches!(transport.requests[1].frames[0], Frame::ReqHeader { .. })
2389                && matches!(transport.requests[1].frames[1], Frame::RoundFailure { .. }),
2390            "the report sits immediately after REQ_HEADER"
2391        );
2392        assert!(
2393            carried_round_failures(&transport.requests[2]).is_empty(),
2394            "a complete response ends the report"
2395        );
2396    }
2397
2398    #[test]
2399    fn a_newer_round_failure_replaces_the_kept_code() {
2400        let mut client = client();
2401        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2402        let mut transport = failure_transport([
2403            Err(TransportError::new("transport.timeout", "round timed out")),
2404            Err(TransportError::new("transport.failed", "socket closed")),
2405            Ok(completed_response(Vec::new())),
2406            Ok(completed_response(Vec::new())),
2407        ]);
2408        for _ in 0..4 {
2409            client.sync(&mut transport);
2410        }
2411        assert_eq!(
2412            carried_round_failures(&transport.requests[1]),
2413            vec!["transport.timeout"]
2414        );
2415        assert_eq!(
2416            carried_round_failures(&transport.requests[2]),
2417            vec!["transport.failed"]
2418        );
2419        assert!(carried_round_failures(&transport.requests[3]).is_empty());
2420    }
2421
2422    #[test]
2423    fn an_in_band_error_completes_the_round_and_is_never_reported() {
2424        let mut client = client();
2425        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2426        let mut transport = failure_transport([
2427            Err(TransportError::new("transport.timeout", "round timed out")),
2428            Ok(completed_response(vec![Frame::Error {
2429                code: "sync.internal_error".into(),
2430                message: "server error".into(),
2431                category: "internal".into(),
2432                retryable: false,
2433                recommended_action: "retryLater".into(),
2434                details: None,
2435            }])),
2436            Ok(completed_response(Vec::new())),
2437        ]);
2438        client.sync(&mut transport);
2439        assert!(matches!(
2440            client.sync(&mut transport),
2441            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
2442        ));
2443        client.sync(&mut transport);
2444        assert_eq!(
2445            carried_round_failures(&transport.requests[1]),
2446            vec!["transport.timeout"]
2447        );
2448        assert!(
2449            carried_round_failures(&transport.requests[2]).is_empty(),
2450            "the server already recorded an in-band ERROR"
2451        );
2452    }
2453
2454    #[test]
2455    fn a_silent_segment_fetch_is_retryable_and_reported_on_the_next_request() {
2456        let mut client = client();
2457        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2458        client
2459            .subscribe(
2460                "tasks".into(),
2461                "tasks".into(),
2462                vec![("project_id".into(), vec!["p1".into()])],
2463                None,
2464            )
2465            .unwrap();
2466        client.subs[0].cursor = 0;
2467        client.subs[0].synced_once = true;
2468        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2469        client.persist_sub(&client.subs[0]).unwrap();
2470        client.drain_change_batches();
2471        client.drain_sync_intents();
2472        let mut transport = failure_transport([
2473            Ok(completed_response(vec![
2474                Frame::SubStart {
2475                    id: "tasks".into(),
2476                    status: SubStatus::Active,
2477                    reason_code: String::new(),
2478                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2479                    bootstrap: false,
2480                },
2481                Frame::SegmentRef {
2482                    segment_id: "sha256:stalled".into(),
2483                    media_type: MediaType::Rows,
2484                    table: "tasks".into(),
2485                    byte_length: 1,
2486                    row_count: 1,
2487                    as_of_commit_seq: 1,
2488                    scope_digest: "digest".into(),
2489                    row_cursor: None,
2490                    next_row_cursor: None,
2491                    url: None,
2492                    url_expires_at_ms: None,
2493                },
2494                Frame::SubEnd {
2495                    next_cursor: 1,
2496                    bootstrap_state: None,
2497                },
2498            ])),
2499            Ok(completed_response(Vec::new())),
2500            Ok(completed_response(Vec::new())),
2501        ]);
2502
2503        assert!(matches!(
2504            client.sync(&mut transport),
2505            SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
2506        ));
2507        assert!(
2508            matches!(
2509                client.drain_sync_intents().as_slice(),
2510                [SyncIntent::Background { delay_ms: 250 }]
2511            ),
2512            "a silent transfer asks the host for a Background retry"
2513        );
2514        client.sync(&mut transport);
2515        client.sync(&mut transport);
2516        assert!(carried_round_failures(&transport.requests[0]).is_empty());
2517        assert_eq!(
2518            carried_round_failures(&transport.requests[1]),
2519            vec!["transport.timeout"],
2520            "the round's own response was complete, so the fetch failure rides the next request"
2521        );
2522        assert!(
2523            carried_round_failures(&transport.requests[2]).is_empty(),
2524            "a complete response ends the report"
2525        );
2526    }
2527
2528    #[test]
2529    fn a_code_the_frame_grammar_rejects_is_not_attached() {
2530        let mut client = client();
2531        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2532        let mut transport = failure_transport([
2533            Err(TransportError::new("offline", "no dotted code")),
2534            Ok(completed_response(Vec::new())),
2535        ]);
2536        client.sync(&mut transport);
2537        client.sync(&mut transport);
2538        assert!(carried_round_failures(&transport.requests[1]).is_empty());
2539    }
2540
2541    #[test]
2542    fn sync_report_merge_schema_floor_is_latest_state() {
2543        let mut aggregate = SyncReport {
2544            schema_floor: Some(SchemaFloor {
2545                required_schema_version: Some(2),
2546                latest_schema_version: Some(2),
2547            }),
2548            ..SyncReport::default()
2549        };
2550        aggregate.merge(&SyncReport::default());
2551        assert!(
2552            aggregate.schema_floor.is_none(),
2553            "a later round without a floor clears it"
2554        );
2555    }
2556
2557    #[test]
2558    fn push_capacity_limits_are_hard_and_keep_intent() {
2559        // The configured operation cap is hard for the first commit.
2560        let mut c = client();
2561        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2562        c.limits.max_push_operations_per_request = Some(1);
2563        let id = c
2564            .mutate(vec![
2565                Mutation::Upsert {
2566                    table: "tasks".into(),
2567                    values: Map::from_iter([
2568                        ("id".into(), json!("a")),
2569                        ("project_id".into(), json!("p1")),
2570                    ]),
2571                    base_version: None,
2572                },
2573                Mutation::Upsert {
2574                    table: "tasks".into(),
2575                    values: Map::from_iter([
2576                        ("id".into(), json!("b")),
2577                        ("project_id".into(), json!("p1")),
2578                    ]),
2579                    base_version: None,
2580                },
2581            ])
2582            .unwrap();
2583        let error = c.build_request(false).unwrap_err();
2584        let SyncOutcome::Failed {
2585            error_code,
2586            details,
2587            ..
2588        } = *error
2589        else {
2590            panic!("expected a capacity failure");
2591        };
2592        assert_eq!(error_code, "client.push_request_too_large");
2593        let details = details.expect("capacity details");
2594        assert_eq!(details["kind"], json!("operations"));
2595        assert_eq!(details["limit"], json!(1));
2596        assert_eq!(details["size"], json!(2));
2597        assert_eq!(details["clientCommitId"], json!(id));
2598        assert_eq!(c.pending_commit_ids(), vec![id]);
2599
2600        // The commit-count cap defers whole later commits.
2601        let mut c = client();
2602        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2603        c.limits.max_push_commits_per_request = Some(1);
2604        let first = c
2605            .mutate(vec![Mutation::Upsert {
2606                table: "tasks".into(),
2607                values: Map::from_iter([
2608                    ("id".into(), json!("one")),
2609                    ("project_id".into(), json!("p1")),
2610                ]),
2611                base_version: None,
2612            }])
2613            .unwrap();
2614        let second = c
2615            .mutate(vec![Mutation::Upsert {
2616                table: "tasks".into(),
2617                values: Map::from_iter([
2618                    ("id".into(), json!("two")),
2619                    ("project_id".into(), json!("p1")),
2620                ]),
2621                base_version: None,
2622            }])
2623            .unwrap();
2624        let (_, meta) = c.build_request(false).unwrap();
2625        assert_eq!(meta.pushed_ids, vec![first.clone()]);
2626        assert_eq!(meta.deferred_commits, 1);
2627        assert_eq!(c.pending_commit_ids(), vec![first, second]);
2628    }
2629
2630    #[test]
2631    fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
2632        // Two small commits: a cap just below the full request defers the
2633        // last commit and sends the fitting prefix (header + first commit).
2634        let mut c = client();
2635        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2636        let first = c
2637            .mutate(vec![Mutation::Upsert {
2638                table: "tasks".into(),
2639                values: Map::from_iter([
2640                    ("id".into(), json!("one")),
2641                    ("project_id".into(), json!("p1")),
2642                ]),
2643                base_version: None,
2644            }])
2645            .unwrap();
2646        let second = c
2647            .mutate(vec![Mutation::Upsert {
2648                table: "tasks".into(),
2649                values: Map::from_iter([
2650                    ("id".into(), json!("two")),
2651                    ("project_id".into(), json!("p1")),
2652                ]),
2653                base_version: None,
2654            }])
2655            .unwrap();
2656        let (message, meta) = c.build_request(false).unwrap();
2657        assert_eq!(meta.pushed_ids.len(), 2);
2658        let full = encode_message(&message).len();
2659        c.limits.max_push_request_bytes = Some(full - 1);
2660        let (_, meta) = c.build_request(false).unwrap();
2661        assert_eq!(meta.pushed_ids, vec![first.clone()]);
2662        assert_eq!(meta.deferred_commits, 1);
2663        assert_eq!(c.pending_commit_ids(), vec![first, second]);
2664
2665        // A single commit larger than the budget is a typed capacity error and
2666        // stays queued, reporting the full projected request size.
2667        let mut c = client();
2668        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2669        let huge = c
2670            .mutate(vec![Mutation::Upsert {
2671                table: "tasks".into(),
2672                values: Map::from_iter([
2673                    ("id".into(), json!("x".repeat(20_000))),
2674                    ("project_id".into(), json!("p1")),
2675                ]),
2676                base_version: None,
2677            }])
2678            .unwrap();
2679        let (message, meta) = c.build_request(false).unwrap();
2680        assert_eq!(meta.pushed_ids, vec![huge.clone()]);
2681        let with_huge = encode_message(&message).len();
2682        c.limits.max_push_request_bytes = Some(with_huge - 1);
2683        let error = c.build_request(false).unwrap_err();
2684        let SyncOutcome::Failed {
2685            error_code,
2686            details,
2687            ..
2688        } = *error
2689        else {
2690            panic!("expected a capacity failure");
2691        };
2692        assert_eq!(error_code, "client.push_request_too_large");
2693        let details = details.expect("capacity details");
2694        assert_eq!(details["kind"], json!("bytes"));
2695        assert_eq!(details["limit"], json!((with_huge - 1) as u64));
2696        assert_eq!(details["size"], json!(with_huge as u64));
2697        assert_eq!(details["clientCommitId"], json!(huge));
2698        assert_eq!(c.pending_commit_ids(), vec![huge]);
2699
2700        // A cap smaller than the fixed frames reports no blocked commit.
2701        let mut c = client();
2702        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2703        c.limits.max_push_request_bytes = Some(1);
2704        let error = c.build_request(false).unwrap_err();
2705        let SyncOutcome::Failed { details, .. } = *error else {
2706            panic!("expected a capacity failure");
2707        };
2708        let details = details.expect("capacity details");
2709        assert_eq!(details["kind"], json!("bytes"));
2710        assert!(details.get("clientCommitId").is_none(), "{details}");
2711    }
2712
2713    #[test]
2714    fn push_limits_validate_at_construction() {
2715        for limits in [
2716            ClientLimits {
2717                max_push_commits_per_request: Some(0),
2718                ..Default::default()
2719            },
2720            ClientLimits {
2721                max_push_operations_per_request: Some(0),
2722                ..Default::default()
2723            },
2724            ClientLimits {
2725                max_push_request_bytes: Some(0),
2726                ..Default::default()
2727            },
2728            ClientLimits {
2729                max_push_request_bytes: Some(u32::MAX as usize + 1),
2730                ..Default::default()
2731            },
2732        ] {
2733            let schema = json!({"version":1,"tables":[]});
2734            let error = SyncClient::with_connection(
2735                "bad-limits".into(),
2736                &schema,
2737                limits,
2738                Connection::open_in_memory().unwrap(),
2739            )
2740            .err();
2741            assert_eq!(
2742                error.as_deref(),
2743                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2744            );
2745        }
2746    }
2747
2748    #[test]
2749    fn open_path_rejects_invalid_push_limits_before_storage() {
2750        for identity in [false, true] {
2751            let path = std::env::temp_dir().join(format!(
2752                "syncular-bad-limits-{}.sqlite",
2753                uuid::Uuid::new_v4()
2754            ));
2755            let schema = json!({"version":1,"tables":[]});
2756            let limits = ClientLimits {
2757                max_push_request_bytes: Some(0),
2758                ..Default::default()
2759            };
2760            let error = if identity {
2761                SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2762            } else {
2763                SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2764            }
2765            .err();
2766            assert_eq!(
2767                error.as_deref(),
2768                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2769            );
2770            assert!(
2771                !path.exists(),
2772                "an invalid limit is rejected before the database file is created"
2773            );
2774        }
2775    }
2776
2777    #[test]
2778    fn failed_progress_retry_delay_doubles_to_the_cap() {
2779        let mut client = client();
2780        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2781        let mut transport = ServerErrorTransport {
2782            code: "sync.internal_error",
2783            retryable: true,
2784        };
2785        let delays: Vec<Option<u64>> = (0..9)
2786            .map(|_| {
2787                client.sync(&mut transport);
2788                client.progress().snapshot().unwrap().retry_delay_ms
2789            })
2790            .collect();
2791        assert_eq!(
2792            delays,
2793            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2794        );
2795    }
2796
2797    #[test]
2798    fn background_retry_deadlines_back_off_and_reset() {
2799        let mut client = client();
2800        client.schedule_background_retry();
2801        assert!(matches!(
2802            client.drain_sync_intents().as_slice(),
2803            [SyncIntent::Background { delay_ms: 250 }]
2804        ));
2805        client.schedule_background_retry();
2806        assert!(matches!(
2807            client.drain_sync_intents().as_slice(),
2808            [SyncIntent::Background { delay_ms: 500 }]
2809        ));
2810        client.reset_background_retry();
2811        client.schedule_background_retry();
2812        assert!(matches!(
2813            client.drain_sync_intents().as_slice(),
2814            [SyncIntent::Background { delay_ms: 250 }]
2815        ));
2816    }
2817
2818    #[test]
2819    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2820        let mut client = client();
2821        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2822        client
2823            .subscribe(
2824                "epoch-tasks".to_owned(),
2825                "tasks".to_owned(),
2826                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2827                None,
2828            )
2829            .expect("subscribe");
2830        client.drain_sync_intents();
2831
2832        client
2833            .run_log_epoch_reset("epoch-2")
2834            .expect("reset partition log epoch");
2835
2836        assert!(matches!(
2837            client.drain_sync_intents().as_slice(),
2838            [SyncIntent::Interactive]
2839        ));
2840    }
2841
2842    #[derive(Default)]
2843    struct CountingRealtimeTransport {
2844        connects: usize,
2845        closes: usize,
2846        messages: Vec<String>,
2847        blob_uploads: Vec<(String, Vec<u8>)>,
2848        blob_download: Option<Vec<u8>>,
2849        sync_calls: usize,
2850        realtime_sync_calls: usize,
2851        refuse_connect: bool,
2852    }
2853
2854    impl Transport for CountingRealtimeTransport {
2855        fn blob_upload(
2856            &mut self,
2857            blob_id: &str,
2858            bytes: &[u8],
2859            _media_type: Option<&str>,
2860        ) -> Result<(), TransportError> {
2861            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2862            Ok(())
2863        }
2864
2865        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2866            self.blob_download
2867                .clone()
2868                .map(BlobDownload::Bytes)
2869                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2870        }
2871
2872        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2873            self.sync_calls += 1;
2874            Err(TransportError::new("sync.transport_failed", "offline"))
2875        }
2876
2877        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2878            self.realtime_sync_calls += 1;
2879            Err(TransportError::new("sync.transport_failed", "offline"))
2880        }
2881
2882        fn download_segment(
2883            &mut self,
2884            _request: &SegmentRequest,
2885            _on_progress: &mut dyn FnMut(u64),
2886        ) -> Result<Vec<u8>, TransportError> {
2887            Err(TransportError::new("sync.transport_failed", "offline"))
2888        }
2889
2890        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2891            self.connects += 1;
2892            if self.refuse_connect {
2893                return Err(TransportError::new("transport.failed", "refused"));
2894            }
2895            Ok(())
2896        }
2897
2898        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2899            self.messages.push(text.to_owned());
2900            Ok(())
2901        }
2902
2903        fn realtime_close(&mut self) -> Result<(), TransportError> {
2904            self.closes += 1;
2905            Ok(())
2906        }
2907    }
2908
2909    #[test]
2910    fn blob_results_own_downloaded_bytes() {
2911        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2912            {"name":"id","type":"string","nullable":false},
2913            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2914        let bytes = b"owned typed result".to_vec();
2915        let blob_id = blob_id_for(&bytes);
2916        let mut transport = CountingRealtimeTransport {
2917            blob_download: Some(bytes.clone()),
2918            ..Default::default()
2919        };
2920        let result = {
2921            let mut client =
2922                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2923                    .unwrap();
2924            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2925            assert_eq!(result.blob_id, blob_id);
2926            assert_eq!(result.byte_length, bytes.len() as i64);
2927            assert_eq!(result.bytes, bytes);
2928            assert_eq!(result.media_type, None);
2929            assert_eq!(
2930                client
2931                    .conn
2932                    .query_row(
2933                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2934                        rusqlite::params![blob_id],
2935                        |row| row.get::<_, i64>(0),
2936                    )
2937                    .unwrap(),
2938                0
2939            );
2940            result
2941        };
2942        assert_eq!(result.bytes, bytes);
2943
2944        let mut client =
2945            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2946        assert_eq!(
2947            client
2948                .fetch_blob_bytes(&mut transport, "not json")
2949                .unwrap_err(),
2950            (
2951                "client.failed".to_owned(),
2952                "blob ref is not JSON".to_owned()
2953            )
2954        );
2955    }
2956
2957    #[test]
2958    fn visible_blob_references_survive_cache_trimming() {
2959        let schema = json!({"version":1,"tables":[
2960            {"name":"attachments","primaryKey":"id","columns":[
2961                {"name":"id","type":"string","nullable":false},
2962                {"name":"body","type":"blob_ref","nullable":true},
2963                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2964            {"name":"avatars","primaryKey":"id","columns":[
2965                {"name":"id","type":"string","nullable":false},
2966                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2967        ]});
2968        let bytes = b"referenced above cap".to_vec();
2969        let blob_id = blob_id_for(&bytes);
2970        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2971        let mut client = SyncClient::new(
2972            "visible-blob-refs".to_owned(),
2973            &schema,
2974            ClientLimits {
2975                blob_cache_max_bytes: Some(1),
2976                ..Default::default()
2977            },
2978        )
2979        .unwrap();
2980        client.conn.execute(
2981            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2982            rusqlite::params![reference, reference, reference, reference],
2983        ).unwrap();
2984        client
2985            .conn
2986            .execute(
2987                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2988                rusqlite::params![reference],
2989            )
2990            .unwrap();
2991        client.conn.execute(
2992            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2993            [],
2994        ).unwrap();
2995        let mut transport = CountingRealtimeTransport {
2996            blob_download: Some(bytes.clone()),
2997            ..Default::default()
2998        };
2999
3000        assert_eq!(
3001            client
3002                .fetch_blob_bytes(&mut transport, &blob_id)
3003                .unwrap()
3004                .bytes,
3005            bytes
3006        );
3007        let rows = client
3008            .conn
3009            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
3010            .unwrap()
3011            .query_map([], |row| row.get::<_, String>(0))
3012            .unwrap()
3013            .collect::<Result<Vec<_>, _>>()
3014            .unwrap();
3015        assert_eq!(rows, vec![blob_id]);
3016    }
3017
3018    #[test]
3019    fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
3020        let mut transport = CountingRealtimeTransport::default();
3021        let path = std::env::temp_dir().join(format!(
3022            "syncular-transport-gate-{}.db",
3023            uuid::Uuid::new_v4()
3024        ));
3025        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
3026            {"name":"id","type":"string","nullable":false},
3027            {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
3028        let mut client = SyncClient::open_path(
3029            "gate".into(),
3030            &schema,
3031            ClientLimits::default(),
3032            path.to_str().unwrap(),
3033        )
3034        .unwrap();
3035        client.connect_realtime(&mut transport).unwrap();
3036        client.set_transport_enabled(&mut transport, false);
3037        assert_eq!(transport.closes, 1);
3038        client.begin_security_preflight();
3039        client.activate_security(Default::default()).unwrap();
3040        client
3041            .subscribe(
3042                "tasks".into(),
3043                "tasks".into(),
3044                vec![("project_id".into(), vec!["p1".into()])],
3045                None,
3046            )
3047            .unwrap();
3048        for id in ["one", "two"] {
3049            client
3050                .mutate(vec![Mutation::Upsert {
3051                    table: "tasks".into(),
3052                    values: Map::from_iter([
3053                        ("id".into(), json!(id)),
3054                        ("project_id".into(), json!("p1")),
3055                    ]),
3056                    base_version: None,
3057                }])
3058                .unwrap();
3059        }
3060        assert_eq!(
3061            client
3062                .query("SELECT id FROM tasks ORDER BY id", &[])
3063                .unwrap()
3064                .len(),
3065            2
3066        );
3067        assert!(
3068            matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
3069        );
3070        assert!(client
3071            .connect_realtime(&mut transport)
3072            .unwrap_err()
3073            .starts_with("sync.offline:"));
3074        assert!(client
3075            .set_presence(&mut transport, "project:p1", None)
3076            .unwrap_err()
3077            .starts_with("sync.offline:"));
3078        assert_eq!(transport.sync_calls, 0);
3079        assert_eq!(transport.realtime_sync_calls, 0);
3080        assert_eq!(transport.connects, 1);
3081        assert!(transport.messages.is_empty());
3082        assert!(matches!(
3083            client.drain_sync_intents().as_slice(),
3084            [SyncIntent::None]
3085        ));
3086        let pending = client.pending_commit_ids();
3087        drop(client);
3088        let mut reopened = SyncClient::open_path(
3089            "gate".into(),
3090            &schema,
3091            ClientLimits::default(),
3092            path.to_str().unwrap(),
3093        )
3094        .unwrap();
3095        assert!(reopened.transport_enabled());
3096        reopened.set_transport_enabled(&mut transport, false);
3097        assert_eq!(reopened.pending_commit_ids(), pending);
3098        assert_eq!(
3099            reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
3100            2
3101        );
3102        reopened.set_transport_enabled(&mut transport, true);
3103        assert!(matches!(
3104            reopened.drain_sync_intents().as_slice(),
3105            [SyncIntent::Interactive]
3106        ));
3107        reopened.set_transport_enabled(&mut transport, true);
3108        assert!(reopened.drain_sync_intents().is_empty());
3109        drop(reopened);
3110        std::fs::remove_file(path).unwrap();
3111    }
3112
3113    #[test]
3114    fn realtime_connection_ownership_is_idempotent() {
3115        let mut client = client();
3116        let mut transport = CountingRealtimeTransport {
3117            connects: 0,
3118            closes: 0,
3119            ..Default::default()
3120        };
3121        client
3122            .connect_realtime(&mut transport)
3123            .expect("first connect");
3124        client
3125            .connect_realtime(&mut transport)
3126            .expect("idempotent connect");
3127        assert_eq!(transport.connects, 1);
3128        client.disconnect_realtime(&mut transport);
3129        client.disconnect_realtime(&mut transport);
3130        assert_eq!(transport.closes, 1);
3131        client
3132            .connect_realtime(&mut transport)
3133            .expect("deliberate reconnect");
3134        assert_eq!(transport.connects, 2);
3135    }
3136
3137    #[test]
3138    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
3139        let mut client = client();
3140        client
3141            .set_realtime_policy(RealtimePolicy::Required)
3142            .expect("set policy");
3143        let mut transport = CountingRealtimeTransport::default();
3144        match client.sync(&mut transport) {
3145            SyncOutcome::RealtimeUnavailable {
3146                state,
3147                reason_code,
3148                retry_delay_ms,
3149            } => {
3150                assert_eq!(state, RealtimeState::Disconnected);
3151                assert_eq!(reason_code, None);
3152                assert_eq!(retry_delay_ms, 250);
3153            }
3154            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3155        }
3156        assert_eq!(transport.sync_calls, 0);
3157        assert!(matches!(
3158            client.drain_sync_intents().as_slice(),
3159            [SyncIntent::Background { delay_ms: 250 }]
3160        ));
3161        let snapshot = client
3162            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3163            .expect("diagnostics");
3164        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3165        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
3166        assert_eq!(snapshot.host.realtime_reason_code, None);
3167        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3168        assert_eq!(
3169            snapshot
3170                .last_round
3171                .as_ref()
3172                .and_then(|round| round.error_code.clone()),
3173            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
3174        );
3175    }
3176
3177    #[test]
3178    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
3179        let mut client = client();
3180        client
3181            .set_realtime_policy(RealtimePolicy::Required)
3182            .expect("set policy");
3183        let mut transport = CountingRealtimeTransport {
3184            refuse_connect: true,
3185            ..Default::default()
3186        };
3187        assert!(client.connect_realtime(&mut transport).is_err());
3188        assert_eq!(client.realtime_state(), RealtimeState::Refused);
3189        let snapshot = client
3190            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3191            .expect("diagnostics");
3192        assert_eq!(
3193            snapshot.host.realtime_reason_code.as_deref(),
3194            Some("transport.failed")
3195        );
3196        match client.sync(&mut transport) {
3197            SyncOutcome::RealtimeUnavailable {
3198                state, reason_code, ..
3199            } => {
3200                assert_eq!(state, RealtimeState::Refused);
3201                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
3202            }
3203            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3204        }
3205        assert_eq!(transport.sync_calls, 0);
3206        assert_eq!(transport.realtime_sync_calls, 0);
3207    }
3208
3209    #[test]
3210    fn an_unsolicited_socket_end_is_lost_with_the_transport_code_and_a_retry_intent() {
3211        let mut client = client();
3212        let mut transport = CountingRealtimeTransport::default();
3213        let ended = TransportError::new("transport.failed", "realtime connection closed");
3214        client.realtime_lost(&mut transport, &ended);
3215        assert_eq!(
3216            client.realtime_state(),
3217            RealtimeState::Disconnected,
3218            "a socket that was never connected has nothing to lose"
3219        );
3220        assert!(client.drain_sync_intents().is_empty());
3221
3222        client.connect_realtime(&mut transport).expect("connect");
3223        client.realtime_lost(&mut transport, &ended);
3224        assert_eq!(client.realtime_state(), RealtimeState::Lost);
3225        assert_eq!(transport.closes, 1);
3226        assert!(matches!(
3227            client.drain_sync_intents().as_slice(),
3228            [SyncIntent::Background { delay_ms: 250 }]
3229        ));
3230        let snapshot = client
3231            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3232            .expect("diagnostics");
3233        assert_eq!(snapshot.host.realtime, RealtimeState::Lost);
3234        assert_eq!(
3235            snapshot.host.realtime_reason_code.as_deref(),
3236            Some("transport.failed")
3237        );
3238        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3239
3240        client.realtime_lost(&mut transport, &ended);
3241        assert!(
3242            client.drain_sync_intents().is_empty(),
3243            "a second report for the same loss schedules nothing"
3244        );
3245        client.connect_realtime(&mut transport).expect("reconnect");
3246        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3247    }
3248
3249    #[test]
3250    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
3251        let mut client = client();
3252        client
3253            .set_realtime_policy(RealtimePolicy::Required)
3254            .expect("set policy");
3255        let mut transport = CountingRealtimeTransport::default();
3256        client.connect_realtime(&mut transport).expect("connect");
3257        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3258        assert!(
3259            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
3260            "a policy change cannot silently re-route a connected socket"
3261        );
3262        assert!(matches!(
3263            client.sync(&mut transport),
3264            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
3265        ));
3266        assert_eq!(client.realtime_state(), RealtimeState::Lost);
3267        assert_eq!(transport.realtime_sync_calls, 1);
3268        assert_eq!(transport.sync_calls, 0);
3269        let snapshot = client
3270            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3271            .expect("diagnostics");
3272        assert_eq!(
3273            snapshot.host.realtime_reason_code.as_deref(),
3274            Some("sync.transport_failed")
3275        );
3276        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3277        match client.sync(&mut transport) {
3278            SyncOutcome::RealtimeUnavailable {
3279                state, reason_code, ..
3280            } => {
3281                assert_eq!(state, RealtimeState::Lost);
3282                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
3283            }
3284            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3285        }
3286        assert_eq!(transport.sync_calls, 0);
3287        client.disconnect_realtime(&mut transport);
3288        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
3289        assert_eq!(transport.closes, 1);
3290        client.connect_realtime(&mut transport).expect("reconnect");
3291        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3292        assert_eq!(transport.connects, 2);
3293    }
3294
3295    #[test]
3296    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
3297        let mut client = client();
3298        client
3299            .set_realtime_policy(RealtimePolicy::Off)
3300            .expect("set policy");
3301        let mut transport = CountingRealtimeTransport::default();
3302        assert!(client.connect_realtime(&mut transport).is_err());
3303        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
3304        assert!(matches!(
3305            client.sync(&mut transport),
3306            SyncOutcome::Failed { .. }
3307        ));
3308        assert_eq!(transport.sync_calls, 1);
3309        assert_eq!(transport.realtime_sync_calls, 0);
3310        let snapshot = client
3311            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3312            .expect("diagnostics");
3313        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
3314        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
3315    }
3316
3317    #[test]
3318    fn optional_policy_keeps_the_http_round() {
3319        let mut client = client();
3320        let mut transport = CountingRealtimeTransport::default();
3321        assert!(matches!(
3322            client.sync(&mut transport),
3323            SyncOutcome::Failed { .. }
3324        ));
3325        assert_eq!(transport.sync_calls, 1);
3326        assert_eq!(transport.realtime_sync_calls, 0);
3327        let snapshot = client
3328            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3329            .expect("diagnostics");
3330        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3331        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
3332    }
3333
3334    #[test]
3335    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
3336        let mut client = client();
3337        client
3338            .subscribe(
3339                "repair-tasks".to_owned(),
3340                "tasks".to_owned(),
3341                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3342                None,
3343            )
3344            .expect("subscribe");
3345        {
3346            let sub = client
3347                .subs
3348                .iter_mut()
3349                .find(|sub| sub.id == "repair-tasks")
3350                .expect("subscription");
3351            sub.cursor = 42;
3352            sub.synced_once = true;
3353            let persisted = sub.clone();
3354            client.persist_sub(&persisted).unwrap();
3355        }
3356        for table in ["_syncular_base_tasks", "tasks"] {
3357            client
3358                .conn
3359                .execute(
3360                    &format!(
3361                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
3362                    ),
3363                    rusqlite::params!["server-row", "p1"],
3364                )
3365                .expect("seed server row");
3366        }
3367        let pending = client
3368            .mutate(vec![Mutation::Upsert {
3369                table: "tasks".to_owned(),
3370                values: Map::from_iter([
3371                    ("id".to_owned(), Value::from("offline-row")),
3372                    ("project_id".to_owned(), Value::from("p1")),
3373                ]),
3374                base_version: None,
3375            }])
3376            .expect("queue offline work");
3377        client.drain_change_batches();
3378        client.drain_sync_intents();
3379
3380        assert_eq!(
3381            client
3382                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3383                    rebootstrap_id: "support-case-001".to_owned(),
3384                })
3385                .expect("rebootstrap"),
3386            LocalDataRebootstrapResult {
3387                already_applied: false,
3388                retained_commits: 1,
3389                reset_subscriptions: 1,
3390            }
3391        );
3392        let visible_ids = client
3393            .conn
3394            .prepare("SELECT id FROM tasks ORDER BY id")
3395            .expect("prepare visible ids")
3396            .query_map([], |row| row.get::<_, String>(0))
3397            .expect("query visible ids")
3398            .collect::<Result<Vec<_>, _>>()
3399            .expect("collect visible ids");
3400        assert_eq!(visible_ids, vec!["offline-row"]);
3401        assert_eq!(client.pending_commit_ids(), vec![pending]);
3402        assert_eq!(
3403            client
3404                .subscription_state("repair-tasks")
3405                .expect("subscription")
3406                .cursor,
3407            -1
3408        );
3409        assert!(client.upgrading());
3410        assert!(client.sync_needed());
3411        assert!(matches!(
3412            client.drain_sync_intents().as_slice(),
3413            [SyncIntent::Interactive]
3414        ));
3415        assert_eq!(client.drain_change_batches().len(), 1);
3416
3417        assert_eq!(
3418            client
3419                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3420                    rebootstrap_id: "support-case-001".to_owned(),
3421                })
3422                .expect("idempotent retry"),
3423            LocalDataRebootstrapResult {
3424                already_applied: true,
3425                retained_commits: 1,
3426                reset_subscriptions: 1,
3427            }
3428        );
3429        assert!(client.drain_change_batches().is_empty());
3430    }
3431
3432    #[test]
3433    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
3434        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
3435        assert_eq!(
3436            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
3437            (3, 4)
3438        );
3439        assert_eq!(
3440            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
3441            (0, 0)
3442        );
3443        for malformed in [
3444            "",
3445            "{}",
3446            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
3447            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
3448            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
3449        ] {
3450            assert_eq!(
3451                decode_local_rebootstrap_receipt(malformed)
3452                    .expect_err("malformed receipt must fail"),
3453                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3454            );
3455        }
3456    }
3457
3458    #[test]
3459    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
3460        let path = std::env::temp_dir().join(format!(
3461            "syncular-rebootstrap-receipt-{}.db",
3462            uuid::Uuid::new_v4()
3463        ));
3464        let schema = json!({
3465            "version": 1,
3466            "tables": [{
3467                "name": "tasks",
3468                "primaryKey": "id",
3469                "columns": [
3470                    { "name": "id", "type": "string", "nullable": false },
3471                    { "name": "project_id", "type": "string", "nullable": false }
3472                ],
3473                "scopes": [{ "pattern": "project:{project_id}" }]
3474            }]
3475        });
3476        let path_string = path.to_str().expect("UTF-8 temp path");
3477
3478        {
3479            let mut first = SyncClient::open_path(
3480                "repair-restart-client".to_owned(),
3481                &schema,
3482                ClientLimits::default(),
3483                path_string,
3484            )
3485            .expect("first open");
3486            first
3487                .subscribe(
3488                    "repair-tasks".to_owned(),
3489                    "tasks".to_owned(),
3490                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3491                    None,
3492                )
3493                .expect("subscribe");
3494            first
3495                .mutate(vec![Mutation::Upsert {
3496                    table: "tasks".to_owned(),
3497                    values: Map::from_iter([
3498                        ("id".to_owned(), Value::from("offline-row")),
3499                        ("project_id".to_owned(), Value::from("p1")),
3500                    ]),
3501                    base_version: None,
3502                }])
3503                .expect("queue offline work");
3504            assert_eq!(
3505                first
3506                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
3507                        rebootstrap_id: "restart-receipt".to_owned(),
3508                    })
3509                    .expect("first rebootstrap"),
3510                LocalDataRebootstrapResult {
3511                    already_applied: false,
3512                    retained_commits: 1,
3513                    reset_subscriptions: 1,
3514                }
3515            );
3516        }
3517
3518        let mut reopened = SyncClient::open_path(
3519            "repair-restart-client".to_owned(),
3520            &schema,
3521            ClientLimits::default(),
3522            path_string,
3523        )
3524        .expect("reopen");
3525        reopened.drain_change_batches();
3526        reopened.drain_sync_intents();
3527        assert_eq!(
3528            reopened
3529                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3530                    rebootstrap_id: "restart-receipt".to_owned(),
3531                })
3532                .expect("receipt replay"),
3533            LocalDataRebootstrapResult {
3534                already_applied: true,
3535                retained_commits: 1,
3536                reset_subscriptions: 1,
3537            }
3538        );
3539        assert!(reopened.drain_change_batches().is_empty());
3540        assert!(reopened.drain_sync_intents().is_empty());
3541        drop(reopened);
3542        std::fs::remove_file(path).expect("remove temp database");
3543    }
3544
3545    #[test]
3546    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
3547        let mut malformed = client();
3548        malformed
3549            .subscribe(
3550                "repair-tasks".to_owned(),
3551                "tasks".to_owned(),
3552                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3553                None,
3554            )
3555            .expect("subscribe");
3556        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
3557        malformed.drain_change_batches();
3558        malformed.drain_sync_intents();
3559        let malformed_error = malformed
3560            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3561                rebootstrap_id: "malformed".to_owned(),
3562            })
3563            .expect_err("malformed receipt must fail closed");
3564        assert_eq!(
3565            malformed_error,
3566            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3567        );
3568        assert!(!malformed.upgrading());
3569        assert_eq!(
3570            malformed
3571                .subscription_state("repair-tasks")
3572                .expect("unchanged subscription")
3573                .cursor,
3574            -1
3575        );
3576        assert!(malformed.drain_change_batches().is_empty());
3577        assert!(malformed.drain_sync_intents().is_empty());
3578
3579        let mut unreadable = client();
3580        unreadable
3581            .conn
3582            .execute(
3583                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
3584                rusqlite::params!["server-row", "p1"],
3585            )
3586            .expect("seed visible row");
3587        unreadable
3588            .conn
3589            .execute("DROP TABLE _syncular_meta", [])
3590            .expect("break marker storage");
3591        let unreadable_error = unreadable
3592            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3593                rebootstrap_id: "unreadable".to_owned(),
3594            })
3595            .expect_err("unreadable marker storage must fail closed");
3596        assert_eq!(
3597            unreadable_error,
3598            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
3599        );
3600        let visible_rows = unreadable
3601            .conn
3602            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
3603            .expect("visible projection remains");
3604        assert_eq!(visible_rows, 1);
3605        assert!(!unreadable.upgrading());
3606        assert!(unreadable.drain_change_batches().is_empty());
3607        assert!(unreadable.drain_sync_intents().is_empty());
3608    }
3609
3610    #[test]
3611    fn local_rebootstrap_cannot_bypass_schema_floor() {
3612        let mut client = client();
3613        client.set_schema_floor(Some(SchemaFloor {
3614            required_schema_version: Some(2),
3615            latest_schema_version: Some(2),
3616        }));
3617        let error = client
3618            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3619                rebootstrap_id: "blocked-floor".to_owned(),
3620            })
3621            .expect_err("schema floor must block repair");
3622        assert!(error.contains("cannot bypass an active schema-floor stop"));
3623    }
3624
3625    #[test]
3626    fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
3627        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3628            "columns":[{"name":"id","type":"string","nullable":false},
3629                {"name":"project_id","type":"string","nullable":false},
3630                {"name":"starts_at_ms","type":"integer","nullable":false},
3631                {"name":"version","type":"integer","nullable":false}],
3632            "scopes":[{"pattern":"project:{project_id}"}]}]});
3633        let mut client =
3634            SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
3635        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3636        client
3637            .write_base_row(
3638                "tasks",
3639                &vec![
3640                    Some(ColumnValue::String("t1".into())),
3641                    Some(ColumnValue::String("p1".into())),
3642                    Some(ColumnValue::Integer(100)),
3643                    Some(ColumnValue::Integer(1)),
3644                ],
3645                1,
3646            )
3647            .unwrap();
3648        client.rebuild_overlay().expect("rebuild overlay");
3649        let first = client
3650            .patch(
3651                "tasks",
3652                "t1",
3653                Map::from_iter([
3654                    ("starts_at_ms".into(), json!(200)),
3655                    ("version".into(), json!(2)),
3656                ]),
3657                None,
3658            )
3659            .unwrap();
3660        let (_, first_request) = client.build_request(false).unwrap();
3661        // This second edit happens while the first request is in flight.
3662        let second = client
3663            .patch(
3664                "tasks",
3665                "t1",
3666                Map::from_iter([
3667                    ("starts_at_ms".into(), json!(300)),
3668                    ("version".into(), json!(3)),
3669                ]),
3670                None,
3671            )
3672            .unwrap();
3673        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3674        for (id, seq, meta) in [
3675            (first, 2, first_request),
3676            (second, 3, client.build_request(false).unwrap().1),
3677        ] {
3678            let response = Message {
3679                wire_version: WIRE_VERSION,
3680                msg_kind: MsgKind::Response,
3681                frames: vec![
3682                    Frame::RespHeader {
3683                        required_schema_version: None,
3684                        latest_schema_version: None,
3685                        log_epoch: Some("epoch-1".into()),
3686                        reset_required: Some(false),
3687                    },
3688                    Frame::PushResult {
3689                        client_commit_id: id,
3690                        status: PushStatus::Applied,
3691                        commit_seq: Some(seq),
3692                        results: vec![OpResult::Applied { op_index: 0 }],
3693                    },
3694                ],
3695            };
3696            assert!(matches!(
3697                client.process_response(&mut transport, response, &meta),
3698                SyncOutcome::Ok(_)
3699            ));
3700            let row = &client
3701                .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
3702                .unwrap()[0];
3703            assert_eq!(
3704                row["starts_at_ms"],
3705                json!(300),
3706                "an applied ACK without a pull image must not expose the old base"
3707            );
3708            assert_eq!(
3709                row["version"],
3710                json!(3),
3711                "the queued domain version must remain monotonic"
3712            );
3713        }
3714    }
3715
3716    #[test]
3717    fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
3718        for stored in [None, Some("epoch-old")] {
3719            let mut client = client();
3720            client
3721                .subscribe(
3722                    "tasks".into(),
3723                    "tasks".into(),
3724                    vec![("project_id".into(), vec!["p1".into()])],
3725                    None,
3726                )
3727                .unwrap();
3728            client
3729                .conn
3730                .execute_batch(
3731                    "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
3732                )
3733                .unwrap();
3734            let id = client
3735                .mutate(vec![Mutation::Upsert {
3736                    table: "tasks".into(),
3737                    values: Map::from_iter([
3738                        ("id".into(), json!("offline")),
3739                        ("project_id".into(), json!("p1")),
3740                    ]),
3741                    base_version: None,
3742                }])
3743                .unwrap();
3744            if let Some(epoch) = stored {
3745                client.set_meta(LOG_EPOCH_KEY, epoch);
3746            }
3747            let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
3748            let (_, meta) = client.build_request(false).unwrap();
3749            client.drain_change_batches();
3750            client.drain_sync_intents();
3751            let response = Message {
3752                wire_version: WIRE_VERSION,
3753                msg_kind: MsgKind::Response,
3754                frames: vec![Frame::RespHeader {
3755                    required_schema_version: None,
3756                    latest_schema_version: None,
3757                    log_epoch: Some("epoch-new".into()),
3758                    reset_required: Some(true),
3759                }],
3760            };
3761            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3762            let outcome = client.process_response(&mut transport, response, &meta);
3763            let SyncOutcome::Ok(report) = outcome else {
3764                panic!("epoch round failed: {outcome:?}");
3765            };
3766            assert_eq!(
3767                report.resets,
3768                if stored.is_none() {
3769                    Vec::<String>::new()
3770                } else {
3771                    vec!["tasks".into()]
3772                }
3773            );
3774            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3775            assert_eq!(client.pending_commit_ids(), vec![id]);
3776            assert_eq!(
3777                client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3778                json!("offline")
3779            );
3780            let trigger: i64 = client
3781                .conn
3782                .query_row(
3783                    "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3784                    [],
3785                    |row| row.get(0),
3786                )
3787                .unwrap();
3788            if stored.is_none() {
3789                assert!(!client.upgrading);
3790                assert!(client
3791                    .drain_change_batches()
3792                    .iter()
3793                    .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3794                assert_eq!(
3795                    serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3796                    before
3797                );
3798                assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3799            } else {
3800                assert!(client.upgrading);
3801                assert_eq!(trigger, 0, "an epoch change must drop the old table");
3802            }
3803            assert!(matches!(
3804                client.drain_sync_intents().as_slice(),
3805                [SyncIntent::Interactive]
3806            ));
3807        }
3808    }
3809
3810    #[test]
3811    fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3812        for key in ["logEpoch", "localRevision"] {
3813            let mut client = client();
3814            let id = client
3815                .mutate(vec![Mutation::Upsert {
3816                    table: "tasks".into(),
3817                    values: Map::from_iter([
3818                        ("id".into(), json!("offline")),
3819                        ("project_id".into(), json!("p1")),
3820                    ]),
3821                    base_version: None,
3822                }])
3823                .unwrap();
3824            let revision = client.local_revision();
3825            let needed = client.sync_needed;
3826            client.drain_change_batches();
3827            client.drain_sync_intents();
3828            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();
3829            assert!(client.run_log_epoch_reset("epoch-new").is_err());
3830            assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3831            assert!(!client.upgrading);
3832            assert_eq!(client.sync_needed, needed);
3833            assert_eq!(client.local_revision(), revision);
3834            assert_eq!(client.pending_commit_ids(), [id]);
3835            assert!(client.drain_change_batches().is_empty());
3836            assert!(client.drain_sync_intents().is_empty());
3837        }
3838    }
3839
3840    #[test]
3841    fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3842        for key in [
3843            "localSchemaVersion",
3844            "localSchemaDescriptor",
3845            "logEpoch",
3846            "localRevision",
3847        ] {
3848            let mut client = client();
3849            client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3850            let commit = client
3851                .mutate(vec![Mutation::Upsert {
3852                    table: "tasks".into(),
3853                    values: Map::from_iter([
3854                        ("id".into(), json!("offline")),
3855                        ("project_id".into(), json!("p1")),
3856                    ]),
3857                    base_version: None,
3858                }])
3859                .unwrap();
3860            client.active_round = Some(uuid::Uuid::new_v4());
3861            client.overlay_dirty.set(false);
3862            let round = client.active_round;
3863            let needed = client.sync_needed;
3864            let revision = client.local_revision();
3865            let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3866            client.drain_change_batches();
3867            client.drain_sync_intents();
3868            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();
3869            assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3870            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3871            assert!(!client.upgrading);
3872            assert_eq!(client.active_round, round);
3873            assert!(!client.overlay_dirty.get());
3874            assert_eq!(client.sync_needed, needed);
3875            assert_eq!(client.local_revision(), revision);
3876            assert_eq!(
3877                client.pending_commit_ids().as_slice(),
3878                std::slice::from_ref(&commit)
3879            );
3880            assert_eq!(
3881                serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3882                rows
3883            );
3884            assert!(client.drain_change_batches().is_empty());
3885            assert!(client.drain_sync_intents().is_empty());
3886            assert!(client.conn.is_autocommit());
3887            client
3888                .conn
3889                .execute_batch("DROP TRIGGER fail_reset")
3890                .unwrap();
3891            client.run_log_epoch_reset("epoch-new").unwrap();
3892            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3893            assert_eq!(client.pending_commit_ids(), [commit]);
3894        }
3895    }
3896
3897    #[test]
3898    fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3899        for subscriptions in [0, 2, 6, 12] {
3900            let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3901                { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3902                { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3903            ] }), ClientLimits::default()).unwrap();
3904            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3905            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();
3906            for index in 0..32 {
3907                client
3908                    .mutate(vec![Mutation::Upsert {
3909                        table: "tasks".into(),
3910                        values: Map::from_iter([
3911                            ("id".into(), json!(format!("task-{index}"))),
3912                            ("project_id".into(), json!("p1")),
3913                        ]),
3914                        base_version: None,
3915                    }])
3916                    .unwrap();
3917            }
3918            let (_, meta) = client.build_request(false).unwrap();
3919            let mut frames = vec![Frame::RespHeader {
3920                required_schema_version: None,
3921                latest_schema_version: None,
3922                log_epoch: Some("epoch-1".into()),
3923                reset_required: Some(false),
3924            }];
3925            frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3926                Frame::PushResult {
3927                    client_commit_id: id.clone(),
3928                    status: PushStatus::Applied,
3929                    commit_seq: Some(index as i64 + 1),
3930                    results: vec![OpResult::Applied { op_index: 0 }],
3931                }
3932            }));
3933            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3934            client.acknowledged_replay_count.set(0);
3935            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3936            assert!(matches!(
3937                client.process_response(
3938                    &mut transport,
3939                    Message {
3940                        wire_version: WIRE_VERSION,
3941                        msg_kind: MsgKind::Response,
3942                        frames
3943                    },
3944                    &meta
3945                ),
3946                SyncOutcome::Ok(_)
3947            ));
3948            assert_eq!(client.acknowledged_replay_count.get(), 32);
3949            assert_eq!(
3950                client
3951                    .query(
3952                        "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3953                        &[]
3954                    )
3955                    .unwrap()[0]["n"],
3956                json!(32)
3957            );
3958            assert_eq!(
3959                client
3960                    .query(
3961                        "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3962                        &[]
3963                    )
3964                    .unwrap()[0]["n"],
3965                json!(32)
3966            );
3967            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3968            client.acknowledged_replay_count.set(0);
3969            for index in 0..subscriptions {
3970                client
3971                    .subscribe(
3972                        format!("docs-{index}"),
3973                        "docs".into(),
3974                        vec![("project_id".into(), vec![format!("scope-{index}")])],
3975                        None,
3976                    )
3977                    .unwrap();
3978                let sub = client.subs.len() - 1;
3979                client.subs[sub].effective =
3980                    Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3981                let segment = RowsSegment {
3982                    table: "docs".into(),
3983                    schema_version: 1,
3984                    columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3985                    blocks: if index % 2 == 0 {
3986                        vec![vec![ssp2::segment::SegmentRow {
3987                            values: vec![
3988                                Some(ColumnValue::String(format!("doc-{index}"))),
3989                                Some(ColumnValue::String(format!("scope-{index}"))),
3990                            ],
3991                            server_version: 1,
3992                        }]]
3993                    } else {
3994                        vec![]
3995                    },
3996                };
3997                assert!(client.apply_segment(sub, &segment, true).is_ok());
3998            }
3999            assert_eq!(
4000                client
4001                    .query("SELECT count(*) AS n FROM ack_writes", &[])
4002                    .unwrap()[0]["n"],
4003                json!(0),
4004                "unrelated bootstrap must not restore/replay tasks"
4005            );
4006            assert_eq!(
4007                client.acknowledged_replay_count.get(),
4008                0,
4009                "unrelated bootstrap must not decode task ACKs"
4010            );
4011            assert_eq!(
4012                client
4013                    .query("SELECT count(*) AS n FROM tasks", &[])
4014                    .unwrap()[0]["n"],
4015                json!(32)
4016            );
4017            assert_eq!(
4018                client
4019                    .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
4020                    .unwrap()[0]["n"],
4021                json!(32)
4022            );
4023        }
4024    }
4025
4026    #[test]
4027    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
4028        let mut client = client();
4029        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4030        const COMMIT_COUNT: usize = 32;
4031
4032        for index in 0..COMMIT_COUNT {
4033            client
4034                .mutate(vec![Mutation::Upsert {
4035                    table: "tasks".to_owned(),
4036                    values: Map::from_iter([
4037                        ("id".to_owned(), Value::from(format!("task-{index}"))),
4038                        ("project_id".to_owned(), Value::from("project-1")),
4039                    ]),
4040                    base_version: None,
4041                }])
4042                .expect("queue commit");
4043        }
4044
4045        let (_, request_meta) = client.build_request(false).unwrap();
4046        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
4047        let mut frames = vec![Frame::RespHeader {
4048            required_schema_version: None,
4049            latest_schema_version: None,
4050            log_epoch: Some("epoch-1".to_owned()),
4051            reset_required: Some(false),
4052        }];
4053        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
4054            |(index, client_commit_id)| Frame::PushResult {
4055                client_commit_id: client_commit_id.clone(),
4056                status: PushStatus::Applied,
4057                commit_seq: Some(index as i64 + 1),
4058                results: vec![OpResult::Applied { op_index: 0 }],
4059            },
4060        ));
4061        let response = Message {
4062            wire_version: WIRE_VERSION,
4063            msg_kind: MsgKind::Response,
4064            frames,
4065        };
4066        let mut transport =
4067            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
4068
4069        client.overlay_rebuild_count.set(0);
4070        client.outcome_prune_count.set(0);
4071        let outcome = client.process_response(&mut transport, response, &request_meta);
4072        assert!(matches!(outcome, SyncOutcome::Ok(_)));
4073        assert!(client.pending_commit_ids().is_empty());
4074        assert_eq!(
4075            client.overlay_rebuild_count.get(),
4076            1,
4077            "one response must reconcile its acknowledged commits with one overlay rebuild"
4078        );
4079        assert_eq!(
4080            client.outcome_prune_count.get(),
4081            1,
4082            "one response must enforce outcome retention once"
4083        );
4084    }
4085
4086    #[test]
4087    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
4088        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
4089            "columns": [
4090                {"name":"id", "type":"string", "nullable":false},
4091                {"name":"project_id", "type":"string", "nullable":false},
4092                {"name":"title", "type":"string", "nullable":false}
4093            ], "scopes": [{"pattern":"project:{project_id}"}],
4094            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4095            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
4096        }] });
4097        let mut incremental =
4098            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
4099        let mut reference =
4100            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
4101        let tables = vec!["tasks".to_owned()];
4102        for index in 0..32 {
4103            let id = format!("task-{}", index % 8);
4104            let values = Map::from_iter([
4105                ("id".to_owned(), Value::from(id.clone())),
4106                (
4107                    "project_id".to_owned(),
4108                    Value::from(format!("p{}", index % 2)),
4109                ),
4110                ("title".to_owned(), Value::from(format!("needle{index}"))),
4111            ]);
4112            let payload = encode_row_json(
4113                incremental.schema.table("tasks").unwrap(),
4114                &id,
4115                &values,
4116                &incremental.encryption,
4117            )
4118            .unwrap();
4119            let change = ssp2::model::Change {
4120                table_index: 0,
4121                row_id: id,
4122                op: if index % 5 == 0 {
4123                    Op::Delete
4124                } else {
4125                    Op::Upsert
4126                },
4127                row_version: Some(index + 1),
4128                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
4129                row: Some(payload),
4130            };
4131            reference.overlay_dirty.set(true);
4132            for instance in [&mut incremental, &mut reference] {
4133                instance.begin_observation("mirror_test").unwrap();
4134                let mut batch = ChangeAccumulator::default();
4135                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
4136                instance
4137                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
4138                    .unwrap();
4139                instance
4140                    .rebuild_overlay_if_dirty()
4141                    .expect("rebuild overlay");
4142                instance.finish_observation("mirror_test", batch).unwrap();
4143            }
4144            for sql in [
4145                "SELECT * FROM tasks ORDER BY id",
4146                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4147            ] {
4148                assert_eq!(
4149                    incremental.query(sql, &[]).unwrap(),
4150                    reference.query(sql, &[]).unwrap()
4151                );
4152            }
4153            assert_eq!(incremental.local_revision(), reference.local_revision());
4154            assert_eq!(
4155                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4156                serde_json::to_value(reference.drain_change_batches()).unwrap()
4157            );
4158        }
4159        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4160        assert_eq!(reference.overlay_rebuild_count.get(), 32);
4161    }
4162
4163    #[test]
4164    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
4165        let cases = [
4166            (
4167                "string",
4168                vec![
4169                    json!("1"),
4170                    json!("01"),
4171                    json!(""),
4172                    json!("nul\u{0}id"),
4173                    json!("é"),
4174                ],
4175            ),
4176            (
4177                "integer",
4178                vec![
4179                    json!(i64::MIN),
4180                    json!(-1),
4181                    json!(0),
4182                    json!(1),
4183                    json!(i64::MAX),
4184                ],
4185            ),
4186            ("boolean", vec![json!(false), json!(true)]),
4187            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
4188        ];
4189        for (kind, ids) in cases {
4190            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4191                "columns":[{"name":"id","type":kind,"nullable":false},
4192                    {"name":"project_id","type":"string","nullable":false}],
4193                "scopes":[{"pattern":"project:{project_id}"}]}]});
4194            let mut client =
4195                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
4196            for (index, id) in ids.iter().enumerate() {
4197                let table = client.schema.table("tasks").unwrap();
4198                client
4199                    .write_base_row(
4200                        "tasks",
4201                        &vec![
4202                            if kind == "integer" {
4203                                // Exercise local SQLite key lookup across its
4204                                // full i64 range independently of authoring.
4205                                Some(ColumnValue::Integer(id.as_i64().unwrap()))
4206                            } else {
4207                                json_to_column_value(&table.columns[0], Some(id)).unwrap()
4208                            },
4209                            Some(ColumnValue::String(format!("p{index}"))),
4210                        ],
4211                        1,
4212                    )
4213                    .unwrap();
4214            }
4215            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4216            for base in [true, false] {
4217                let full = if base {
4218                    base_table("tasks")
4219                } else {
4220                    visible_table("tasks")
4221                };
4222                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
4223                for row_id in [
4224                    "1",
4225                    "01",
4226                    "1.0",
4227                    "1.0000000000000002",
4228                    "+1",
4229                    "1e0",
4230                    " 1",
4231                    "-1",
4232                    "0",
4233                    "9223372036854775807",
4234                    "-9223372036854775808",
4235                    "true",
4236                    "false",
4237                    "",
4238                    "nul\u{0}id",
4239                    "é",
4240                    "null",
4241                    "{\"a\":1}",
4242                ] {
4243                    let old_sql =
4244                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
4245                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
4246                    let old = client
4247                        .conn
4248                        .prepare(&old_sql)
4249                        .unwrap()
4250                        .query_map([row_id], |row| row.get::<_, String>(0))
4251                        .unwrap()
4252                        .collect::<Result<Vec<_>, _>>()
4253                        .unwrap();
4254                    let new = client
4255                        .conn
4256                        .prepare_cached(&new_sql)
4257                        .unwrap()
4258                        .query_map([row_id], |row| row.get::<_, String>(0))
4259                        .unwrap()
4260                        .collect::<Result<Vec<_>, _>>()
4261                        .unwrap();
4262                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
4263                    let mut batch = ChangeAccumulator::default();
4264                    assert_eq!(
4265                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
4266                        !old.is_empty()
4267                    );
4268                    let expected = old.first().map(|scope| {
4269                        BTreeMap::from([(
4270                            "tasks".to_owned(),
4271                            Some(BTreeSet::from([format!("project:{scope}")])),
4272                        )])
4273                    });
4274                    assert_eq!(batch.tables, expected.unwrap_or_default());
4275                    client
4276                        .conn
4277                        .execute_batch("SAVEPOINT lookup_delete")
4278                        .unwrap();
4279                    if base {
4280                        client.delete_base_row("tasks", row_id).unwrap();
4281                    } else {
4282                        client
4283                            .apply_outbox_ops(&[OutboxOp {
4284                                upsert: false,
4285                                insert: false,
4286                                table: "tasks".into(),
4287                                row_id: row_id.into(),
4288                                base_version: None,
4289                                values: None,
4290                            }])
4291                            .expect("replay delete");
4292                    }
4293                    let remaining: i64 = client
4294                        .conn
4295                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
4296                            row.get(0)
4297                        })
4298                        .unwrap();
4299                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
4300                    client
4301                        .conn
4302                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
4303                        .unwrap();
4304                    client.overlay_dirty.set(false);
4305                }
4306                for (predicate, expected) in [
4307                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
4308                    (predicate, "SEARCH"),
4309                ] {
4310                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
4311                    let details = client
4312                        .conn
4313                        .prepare(&sql)
4314                        .unwrap()
4315                        .query_map(["1"], |row| row.get::<_, String>(3))
4316                        .unwrap()
4317                        .collect::<Result<Vec<_>, _>>()
4318                        .unwrap();
4319                    assert!(
4320                        details.iter().any(|detail| detail.contains(expected)),
4321                        "{kind}: {details:?}"
4322                    );
4323                }
4324            }
4325        }
4326    }
4327
4328    #[test]
4329    fn primary_keys_reject_every_ineligible_column_type() {
4330        // §2.4: a `rowId` is a string, so a primary key type whose string form
4331        // differs per renderer, or cannot be reproduced by local storage
4332        // comparison, is a schema error rather than a silent lookup miss.
4333        for ty in ["float", "bytes", "crdt", "blob_ref"] {
4334            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4335                "columns":[{"name":"id","type":ty,"nullable":false},
4336                    {"name":"project_id","type":"string","nullable":false}],
4337                "scopes":[{"pattern":"project:{project_id}"}]}]});
4338            let error =
4339                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
4340                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
4341                    Err(error) => error,
4342                };
4343            assert!(
4344                error.contains("has an ineligible column type (§2.4)"),
4345                "{ty}: {error}"
4346            );
4347        }
4348        for ty in ["string", "integer", "boolean", "json"] {
4349            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4350                "columns":[{"name":"id","type":ty,"nullable":false},
4351                    {"name":"project_id","type":"string","nullable":false}],
4352                "scopes":[{"pattern":"project:{project_id}"}]}]});
4353            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
4354                .unwrap_or_else(|error| panic!("{ty}: {error}"));
4355        }
4356    }
4357
4358    #[test]
4359    fn pending_row_reconciliation_matches_full_fifo_replay() {
4360        for unique in [false, true] {
4361            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
4362                "name": name, "primaryKey": "id", "columns": [
4363                    {"name":"id", "type":"string", "nullable":false},
4364                    {"name":"project_id", "type":"string", "nullable":false},
4365                    {"name":"title", "type":"string", "nullable":false}],
4366                "scopes": [{"pattern":"project:{project_id}"}],
4367                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
4368                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
4369            })))});
4370            let mut incremental =
4371                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4372            let mut reference =
4373                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4374            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
4375            for instance in [&mut incremental, &mut reference] {
4376                for table in &tables {
4377                    for index in 0..12 {
4378                        instance
4379                            .write_base_row(
4380                                table,
4381                                &vec![
4382                                    Some(ColumnValue::String(format!("row{index}"))),
4383                                    Some(ColumnValue::String("p0".into())),
4384                                    Some(ColumnValue::String(format!("base{index}"))),
4385                                ],
4386                                1,
4387                            )
4388                            .unwrap();
4389                    }
4390                }
4391                instance
4392                    .rebuild_overlay_if_dirty()
4393                    .expect("rebuild overlay");
4394            }
4395            for index in 0..80 {
4396                let mutations = (0..3)
4397                    .map(|offset| {
4398                        let table = tables[(index + offset) % 2].clone();
4399                        let id = format!("row{}", (index + offset) % 16);
4400                        if (index + offset) % 7 == 0 {
4401                            Mutation::Delete {
4402                                table,
4403                                row_id: id,
4404                                base_version: None,
4405                            }
4406                        } else {
4407                            Mutation::Upsert {
4408                                table,
4409                                values: Map::from_iter([
4410                                    ("id".into(), Value::from(id)),
4411                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
4412                                    (
4413                                        "title".into(),
4414                                        Value::from(format!("needle{}", (index + offset) % 16)),
4415                                    ),
4416                                ]),
4417                                base_version: None,
4418                            }
4419                        }
4420                    })
4421                    .collect::<Vec<_>>();
4422                for instance in [&mut incremental, &mut reference] {
4423                    instance.mutate(mutations.clone()).unwrap();
4424                }
4425                // Most frames touch only the table without unique constraints.
4426                // Mixed frames must retain full replay for both tables.
4427                let changes = (0..3)
4428                    .map(|offset| {
4429                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
4430                        let id = format!("row{}", (index + offset) % 16);
4431                        let values = Map::from_iter([
4432                            ("id".into(), Value::from(id.clone())),
4433                            (
4434                                "project_id".into(),
4435                                Value::from(format!("p{}", (index + 1) % 3)),
4436                            ),
4437                            (
4438                                "title".into(),
4439                                Value::from(format!("remote{index}_{offset}")),
4440                            ),
4441                        ]);
4442                        ssp2::model::Change {
4443                            table_index: table_index as u16,
4444                            row_id: id.clone(),
4445                            op: if (index + offset) % 5 == 0 {
4446                                Op::Delete
4447                            } else {
4448                                Op::Upsert
4449                            },
4450                            row_version: Some(index as i64 + 2),
4451                            scopes: vec![],
4452                            row: Some(
4453                                encode_row_json(
4454                                    incremental.schema.table(&tables[table_index]).unwrap(),
4455                                    &id,
4456                                    &values,
4457                                    &incremental.encryption,
4458                                )
4459                                .unwrap(),
4460                            ),
4461                        }
4462                    })
4463                    .collect::<Vec<_>>();
4464                reference.overlay_dirty.set(true);
4465                incremental
4466                    .apply_commit_frame(&tables, &changes, None)
4467                    .unwrap();
4468                reference
4469                    .apply_commit_frame(&tables, &changes, None)
4470                    .unwrap();
4471                for table in &tables {
4472                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
4473                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
4474                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
4475                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
4476                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
4477                    }
4478                }
4479                assert_eq!(incremental.local_revision(), reference.local_revision());
4480                assert_eq!(
4481                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4482                    serde_json::to_value(reference.drain_change_batches()).unwrap()
4483                );
4484            }
4485            assert_eq!(
4486                incremental.overlay_rebuild_count.get(),
4487                if unique { 81 } else { 21 }
4488            );
4489            assert_eq!(reference.overlay_rebuild_count.get(), 81);
4490        }
4491    }
4492
4493    #[test]
4494    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
4495        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
4496            "columns":[{"name":"id", "type":"string", "nullable":false},
4497                {"name":"title", "type":"string", "nullable":false}],
4498            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
4499        let mut client =
4500            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
4501        for (id, title) in [("a", "original"), ("b", "occupied")] {
4502            client
4503                .write_base_row(
4504                    "tasks",
4505                    &vec![
4506                        Some(ColumnValue::String(id.into())),
4507                        Some(ColumnValue::String(title.into())),
4508                    ],
4509                    1,
4510                )
4511                .unwrap();
4512        }
4513        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4514        let pending = client
4515            .mutate(vec![Mutation::Upsert {
4516                table: "tasks".into(),
4517                values: Map::from_iter([
4518                    ("id".into(), Value::from("a")),
4519                    ("title".into(), Value::from("available")),
4520                ]),
4521                base_version: None,
4522            }])
4523            .unwrap();
4524        assert_eq!(
4525            client
4526                .conn
4527                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4528                    .get::<_, String>(
4529                    0
4530                ))
4531                .unwrap(),
4532            "available"
4533        );
4534        for (title, expected) in [("available", "original"), ("occupied", "available")] {
4535            let change = ssp2::model::Change {
4536                table_index: 0,
4537                row_id: "b".into(),
4538                op: Op::Upsert,
4539                row_version: Some(2),
4540                scopes: vec![],
4541                row: Some(
4542                    encode_row_json(
4543                        client.schema.table("tasks").unwrap(),
4544                        "b",
4545                        &Map::from_iter([
4546                            ("id".into(), Value::from("b")),
4547                            ("title".into(), Value::from(title)),
4548                        ]),
4549                        &client.encryption,
4550                    )
4551                    .unwrap(),
4552                ),
4553            };
4554            client
4555                .apply_commit_frame(&["tasks".into()], &[change], None)
4556                .unwrap();
4557            assert_eq!(
4558                client
4559                    .conn
4560                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4561                        .get::<_, String>(
4562                        0
4563                    ))
4564                    .unwrap(),
4565                expected
4566            );
4567            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
4568        }
4569        assert_eq!(client.overlay_rebuild_count.get(), 3);
4570    }
4571
4572    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
4573        let schema = json!({ "version": 1, "tables": [
4574            { "name":"tasks", "primaryKey":"id", "columns":[
4575                {"name":"id","type":"string","nullable":false},
4576                {"name":"project_id","type":"string","nullable":false}],
4577                "scopes":[{"pattern":"project:{project_id}"}] },
4578            { "name":"catalogue", "primaryKey":"id", "columns":[
4579                {"name":"id","type":"string","nullable":false},
4580                {"name":"project_id","type":"string","nullable":false},
4581                {"name":"title","type":"string","nullable":false}],
4582                "scopes":[{"pattern":"project:{project_id}"}],
4583                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
4584        ] });
4585        let mut client =
4586            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
4587        if unchanged_rows > 0 {
4588            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();
4589        }
4590        client.rebuild_overlay().expect("rebuild overlay");
4591        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();
4592        let mut elapsed = Vec::new();
4593        for round in 1..=rounds {
4594            let pending = client
4595                .mutate(vec![Mutation::Upsert {
4596                    table: "tasks".into(),
4597                    values: Map::from_iter([
4598                        ("id".into(), json!("t1")),
4599                        ("project_id".into(), json!("p1")),
4600                    ]),
4601                    base_version: None,
4602                }])
4603                .unwrap();
4604            let started = std::time::Instant::now();
4605            client
4606                .write_base_row(
4607                    "tasks",
4608                    &vec![
4609                        Some(ColumnValue::String("t1".into())),
4610                        Some(ColumnValue::String("p1".into())),
4611                    ],
4612                    round,
4613                )
4614                .unwrap();
4615            let mut report = SyncReport::default();
4616            client
4617                .handle_push_results(
4618                    &[Frame::PushResult {
4619                        client_commit_id: pending.clone(),
4620                        status: PushStatus::Applied,
4621                        commit_seq: Some(round),
4622                        results: vec![OpResult::Applied { op_index: 0 }],
4623                    }],
4624                    &HashSet::from([pending.as_str()]),
4625                    &HashMap::new(),
4626                    &mut report,
4627                    Some(&pending),
4628                )
4629                .unwrap();
4630            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4631            elapsed.push(started.elapsed().as_nanos());
4632        }
4633        assert_eq!(
4634            client
4635                .conn
4636                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
4637                .unwrap(),
4638            0,
4639            "unchanged catalogue must never be copied"
4640        );
4641        assert_eq!(
4642            client
4643                .conn
4644                .query_row(
4645                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
4646                    [],
4647                    |r| r.get::<_, i64>(0)
4648                )
4649                .unwrap(),
4650            unchanged_rows
4651        );
4652        assert_eq!(
4653            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
4654            json!(rounds)
4655        );
4656        elapsed
4657    }
4658
4659    #[test]
4660    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
4661        let elapsed = measure_scoped_overlay(100_000, 1);
4662        eprintln!(
4663            "table-scoped overlay with 100k unchanged rows: {} ns",
4664            elapsed[0]
4665        );
4666    }
4667
4668    #[test]
4669    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
4670    fn benchmark_scoped_overlay() {
4671        for rows in [0, 100_000, 200_000] {
4672            let mut elapsed = measure_scoped_overlay(rows, 30);
4673            elapsed.sort_unstable();
4674            eprintln!(
4675                "{}",
4676                json!({"unchangedRows": rows, "rounds": elapsed.len(),
4677                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
4678                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
4679            );
4680        }
4681    }
4682
4683    #[test]
4684    fn released_context_discards_in_flight_ack_and_server_rows() {
4685        for barrier in [
4686            "preflight",
4687            "purge",
4688            "rebootstrap",
4689            "subscription",
4690            "failed-purge-replay",
4691            "failed-purge-publish",
4692            "failed-rebootstrap",
4693        ] {
4694            let mut client = client();
4695            client.set_meta(LOG_EPOCH_KEY, "epoch");
4696            client
4697                .subscribe(
4698                    "tasks".into(),
4699                    "tasks".into(),
4700                    vec![("project_id".into(), vec!["p1".into()])],
4701                    None,
4702                )
4703                .unwrap();
4704            let commit = client
4705                .mutate(vec![Mutation::Upsert {
4706                    table: "tasks".into(),
4707                    values: Map::from_iter([
4708                        ("id".into(), json!("local")),
4709                        ("project_id".into(), json!("p1")),
4710                    ]),
4711                    base_version: None,
4712                }])
4713                .unwrap();
4714            let prepared = client.prepare_sync_round(false).unwrap();
4715            client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
4716            let payload = encode_row_json(
4717                &client.schema.tables[0],
4718                "server",
4719                &Map::from_iter([
4720                    ("id".into(), json!("server")),
4721                    ("project_id".into(), json!("p1")),
4722                ]),
4723                &client.encryption,
4724            )
4725            .unwrap();
4726            let completed = crate::CompletedSyncRound {
4727                prepared,
4728                exchange: crate::round::ExchangeResult::Reply {
4729                    transport_failed: false,
4730                    downloads: Default::default(),
4731                    response: Ok(Message {
4732                        wire_version: WIRE_VERSION,
4733                        msg_kind: MsgKind::Response,
4734                        frames: vec![
4735                            Frame::RespHeader {
4736                                required_schema_version: None,
4737                                latest_schema_version: None,
4738                                log_epoch: Some("epoch".into()),
4739                                reset_required: Some(false),
4740                            },
4741                            Frame::PushResult {
4742                                client_commit_id: commit.clone(),
4743                                status: PushStatus::Applied,
4744                                commit_seq: Some(1),
4745                                results: vec![OpResult::Applied { op_index: 0 }],
4746                            },
4747                            Frame::SubStart {
4748                                id: "tasks".into(),
4749                                status: SubStatus::Active,
4750                                reason_code: String::new(),
4751                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4752                                bootstrap: true,
4753                            },
4754                            Frame::Commit {
4755                                commit_seq: 1,
4756                                created_at_ms: 0,
4757                                actor_id: "actor".into(),
4758                                tables: vec!["tasks".into()],
4759                                changes: vec![ssp2::model::Change {
4760                                    table_index: 0,
4761                                    row_id: "server".into(),
4762                                    op: Op::Upsert,
4763                                    row_version: Some(1),
4764                                    scopes: vec![("project_id".into(), "p1".into())],
4765                                    row: Some(payload),
4766                                }],
4767                            },
4768                            Frame::SubEnd {
4769                                next_cursor: 1,
4770                                bootstrap_state: None,
4771                            },
4772                        ],
4773                    }),
4774                },
4775            };
4776            match barrier {
4777                "preflight" => client.begin_security_preflight(),
4778                "purge" => {
4779                    client
4780                        .purge_local_data(&LocalDataPurgeInput {
4781                            purge_id: "pending-round".into(),
4782                            targets: vec![LocalDataPurgeTarget {
4783                                table: "tasks".into(),
4784                                selectors: BTreeMap::from([(
4785                                    "project_id".into(),
4786                                    vec!["p1".into()],
4787                                )]),
4788                            }],
4789                        })
4790                        .unwrap();
4791                }
4792                "rebootstrap" => {
4793                    client
4794                        .rebootstrap_local_data(&LocalDataRebootstrapInput {
4795                            rebootstrap_id: "pending-round".into(),
4796                        })
4797                        .unwrap();
4798                }
4799                "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4800                    let trigger = if barrier == "failed-purge-replay" {
4801                        "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4802                    } else {
4803                        "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4804                    };
4805                    client.conn.execute_batch(trigger).unwrap();
4806                    let revision = client.local_revision();
4807                    if barrier == "failed-rebootstrap" {
4808                        assert!(client
4809                            .rebootstrap_local_data(&LocalDataRebootstrapInput {
4810                                rebootstrap_id: "failed-round".into(),
4811                            })
4812                            .is_err());
4813                    } else {
4814                        assert!(client
4815                            .purge_local_data(&LocalDataPurgeInput {
4816                                purge_id: "failed-round".into(),
4817                                targets: vec![LocalDataPurgeTarget {
4818                                    table: "tasks".into(),
4819                                    selectors: BTreeMap::from([(
4820                                        "project_id".into(),
4821                                        vec!["p1".into()]
4822                                    )]),
4823                                }],
4824                            })
4825                            .is_err());
4826                    }
4827                    assert_eq!(client.local_revision(), revision);
4828                    assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4829                    client
4830                        .conn
4831                        .execute_batch("DROP TRIGGER fail_local")
4832                        .unwrap();
4833                    let applied = client.apply_sync_round(completed);
4834                    assert!(
4835                        matches!(
4836                            applied,
4837                            crate::AppliedSyncRound::Complete {
4838                                outcome: SyncOutcome::Ok(_),
4839                                ..
4840                            }
4841                        ),
4842                        "{barrier}"
4843                    );
4844                    assert!(client.pending_commit_ids().is_empty());
4845                    assert_eq!(
4846                        client
4847                            .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4848                            .unwrap()
4849                            .len(),
4850                        1
4851                    );
4852                    continue;
4853                }
4854                "subscription" => {
4855                    client.unsubscribe("tasks");
4856                    client
4857                        .subscribe(
4858                            "tasks".into(),
4859                            "tasks".into(),
4860                            vec![("project_id".into(), vec!["p2".into()])],
4861                            None,
4862                        )
4863                        .unwrap();
4864                }
4865                _ => unreachable!(),
4866            }
4867            let pending_before_reply = client.pending_commit_ids();
4868            let revision = client.local_revision();
4869            let applied = client.apply_sync_round(completed);
4870            assert!(
4871                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4872            );
4873            assert_eq!(client.local_revision(), revision);
4874            assert!(client
4875                .conn
4876                .query_row(
4877                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4878                    [],
4879                    |r| r.get::<_, bool>(0)
4880                )
4881                .unwrap());
4882            assert_eq!(client.pending_commit_ids(), pending_before_reply);
4883        }
4884    }
4885
4886    #[test]
4887    fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4888        struct DeadlineRecording {
4889            budget: std::time::Duration,
4890            seen: Vec<Option<std::time::Instant>>,
4891        }
4892        impl Transport for DeadlineRecording {
4893            fn round_deadline(&self) -> Option<std::time::Duration> {
4894                Some(self.budget)
4895            }
4896            fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4897                self.seen.push(deadline);
4898            }
4899            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4900                Err(TransportError::new("transport.failed", "injected"))
4901            }
4902            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4903                self.sync(request)
4904            }
4905            fn download_segment(
4906                &mut self,
4907                _: &SegmentRequest,
4908                _: &mut dyn FnMut(u64),
4909            ) -> Result<Vec<u8>, TransportError> {
4910                Err(TransportError::new("transport.failed", "injected"))
4911            }
4912            fn blob_upload_grant(
4913                &mut self,
4914                _: &str,
4915                _: u64,
4916                _: Option<&str>,
4917            ) -> Result<crate::BlobUploadGrant, TransportError> {
4918                Ok(crate::BlobUploadGrant::Present)
4919            }
4920            fn realtime_connect(&mut self) -> Result<(), TransportError> {
4921                Ok(())
4922            }
4923            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4924                Ok(())
4925            }
4926            fn realtime_close(&mut self) -> Result<(), TransportError> {
4927                Ok(())
4928            }
4929        }
4930
4931        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4932            {"name":"id","type":"string","nullable":false},
4933            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4934        let mut client =
4935            SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4936        client.set_meta(LOG_EPOCH_KEY, "epoch");
4937        client
4938            .upload_blob(b"pending upload", Some("text/plain".into()), None)
4939            .unwrap();
4940
4941        let mut transport = DeadlineRecording {
4942            budget: std::time::Duration::from_secs(30),
4943            seen: Vec::new(),
4944        };
4945        let prepared = client.prepare_sync_round(false).unwrap();
4946        assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4947        // Upload exchange -> Continue (the pin removal is persisted by apply).
4948        let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4949            crate::AppliedSyncRound::Continue(prepared) => prepared,
4950            crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4951        };
4952        // Main request exchange -> Complete.
4953        let completed = prepared.exchange(&mut transport);
4954        let anchor = completed
4955            .prepared
4956            .round_deadline_at
4957            .expect("the upload round anchors a deadline");
4958        assert!(matches!(
4959            client.apply_sync_round(completed),
4960            crate::AppliedSyncRound::Complete { .. }
4961        ));
4962        assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4963        assert_eq!(
4964            transport.seen[0],
4965            Some(anchor),
4966            "the upload anchors the round"
4967        );
4968        assert_eq!(
4969            transport.seen[1], None,
4970            "the upload exchange clears its scope"
4971        );
4972        assert_eq!(
4973            transport.seen[2],
4974            Some(anchor),
4975            "a continuation reuses the carried anchor instead of refreshing it"
4976        );
4977        assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4978
4979        // A fresh round anchors a new deadline from the transport's (now larger)
4980        // budget and never inherits the old one; the clock granule is irrelevant.
4981        transport.budget = std::time::Duration::from_secs(60);
4982        let prepared = client.prepare_sync_round(false).unwrap();
4983        let completed = prepared.exchange(&mut transport);
4984        let fresh = completed
4985            .prepared
4986            .round_deadline_at
4987            .expect("the next round anchors a deadline");
4988        client.apply_sync_round(completed);
4989        assert!(
4990            fresh >= anchor + std::time::Duration::from_secs(30),
4991            "the next round anchors a fresh deadline from the new budget"
4992        );
4993        assert_eq!(transport.seen[4], Some(fresh));
4994        assert_eq!(
4995            transport.seen[5], None,
4996            "the new round clears its scope too"
4997        );
4998    }
4999
5000    /// Records the round-deadline traffic so a test can prove whether a round
5001    /// asked for a budget and whether it ever scoped an anchor.
5002    #[derive(Default)]
5003    struct RoundDeadlineProbe {
5004        budget: Option<std::time::Duration>,
5005        budget_queries: std::cell::Cell<usize>,
5006        scoped: Vec<Option<std::time::Instant>>,
5007    }
5008
5009    impl Transport for RoundDeadlineProbe {
5010        fn round_deadline(&self) -> Option<std::time::Duration> {
5011            self.budget_queries.set(self.budget_queries.get() + 1);
5012            self.budget
5013        }
5014        fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
5015            self.scoped.push(deadline);
5016        }
5017        fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
5018            Err(TransportError::new("transport.failed", "injected"))
5019        }
5020        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
5021            self.sync(request)
5022        }
5023        fn download_segment(
5024            &mut self,
5025            _: &SegmentRequest,
5026            _: &mut dyn FnMut(u64),
5027        ) -> Result<Vec<u8>, TransportError> {
5028            Err(TransportError::new("transport.failed", "injected"))
5029        }
5030        fn blob_upload_grant(
5031            &mut self,
5032            _: &str,
5033            _: u64,
5034            _: Option<&str>,
5035        ) -> Result<crate::BlobUploadGrant, TransportError> {
5036            Ok(crate::BlobUploadGrant::Present)
5037        }
5038        fn realtime_connect(&mut self) -> Result<(), TransportError> {
5039            Ok(())
5040        }
5041        fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5042            Ok(())
5043        }
5044        fn realtime_close(&mut self) -> Result<(), TransportError> {
5045            Ok(())
5046        }
5047    }
5048
5049    #[test]
5050    fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
5051        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5052            {"name":"id","type":"string","nullable":false},
5053            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5054        let mut client =
5055            SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
5056        client.set_meta(LOG_EPOCH_KEY, "epoch");
5057        client
5058            .upload_blob(b"pending upload", Some("text/plain".into()), None)
5059            .unwrap();
5060        client
5061            .conn
5062            .execute_batch("DELETE FROM _syncular_blobs")
5063            .unwrap();
5064        let prepared = client.prepare_sync_round(false).unwrap();
5065        assert!(
5066            prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
5067            "the staged body must fail locally"
5068        );
5069        let mut transport = RoundDeadlineProbe {
5070            budget: Some(std::time::Duration::from_secs(30)),
5071            ..Default::default()
5072        };
5073        let completed = prepared.exchange(&mut transport);
5074        assert!(matches!(
5075            completed.exchange,
5076            crate::round::ExchangeResult::Upload { result: Err(_), .. }
5077        ));
5078        assert_eq!(
5079            transport.budget_queries.get(),
5080            0,
5081            "a round with no network work never asks for a budget"
5082        );
5083        assert_eq!(transport.scoped, vec![None]);
5084    }
5085
5086    #[test]
5087    fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
5088        struct HugeBudget;
5089        impl Transport for HugeBudget {
5090            fn round_deadline(&self) -> Option<std::time::Duration> {
5091                Some(std::time::Duration::MAX)
5092            }
5093            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
5094                panic!("no network for an invalid budget")
5095            }
5096            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
5097                self.sync(request)
5098            }
5099            fn download_segment(
5100                &mut self,
5101                _: &SegmentRequest,
5102                _: &mut dyn FnMut(u64),
5103            ) -> Result<Vec<u8>, TransportError> {
5104                panic!("no network for an invalid budget")
5105            }
5106            fn realtime_connect(&mut self) -> Result<(), TransportError> {
5107                Ok(())
5108            }
5109            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5110                Ok(())
5111            }
5112            fn realtime_close(&mut self) -> Result<(), TransportError> {
5113                Ok(())
5114            }
5115        }
5116
5117        let mut client = client();
5118        client.set_meta(LOG_EPOCH_KEY, "epoch");
5119        let prepared = client.prepare_sync_round(false).unwrap();
5120        let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
5121            crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
5122            crate::AppliedSyncRound::Continue(_) => {
5123                panic!("an unrepresentable budget must not continue")
5124            }
5125        };
5126        match outcome {
5127            SyncOutcome::Failed {
5128                error_code,
5129                message,
5130                ..
5131            } => {
5132                assert_eq!(error_code, "sync.invalid_request");
5133                assert_eq!(message, "transport round deadline is out of range");
5134            }
5135            _ => panic!("an unrepresentable budget must fail synchronously"),
5136        }
5137    }
5138
5139    #[test]
5140    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
5141        for failure in ["visible", "revision", "commit"] {
5142            let mut client = client();
5143            let id = client
5144                .mutate(vec![Mutation::Upsert {
5145                    table: "tasks".into(),
5146                    values: Map::from_iter([
5147                        ("id".into(), Value::from("t1")),
5148                        ("project_id".into(), Value::from("pending")),
5149                    ]),
5150                    base_version: None,
5151                }])
5152                .unwrap();
5153            client.drain_change_batches();
5154            let revision = client.local_revision();
5155            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5156            let trigger = match failure {
5157                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
5158                "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"),
5159                _ => "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(),
5160            };
5161            client.conn.execute_batch(&trigger).unwrap();
5162            let change = ssp2::model::Change {
5163                table_index: 0,
5164                row_id: "t1".into(),
5165                op: Op::Upsert,
5166                row_version: Some(2),
5167                scopes: vec![],
5168                row: Some(
5169                    encode_row_json(
5170                        client.schema.table("tasks").unwrap(),
5171                        "t1",
5172                        &Map::from_iter([
5173                            ("id".into(), Value::from("t1")),
5174                            ("project_id".into(), Value::from("remote")),
5175                        ]),
5176                        &client.encryption,
5177                    )
5178                    .unwrap(),
5179                ),
5180            };
5181            assert!(client
5182                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
5183                .is_err());
5184            assert!(client.conn.is_autocommit());
5185            assert!(!client.overlay_dirty.get());
5186            assert_eq!(client.local_revision(), revision);
5187            assert!(client.drain_change_batches().is_empty());
5188            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5189            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5190            assert!(client
5191                .query("SELECT * FROM _syncular_base_tasks", &[])
5192                .unwrap()
5193                .is_empty());
5194            client
5195                .conn
5196                .execute_batch("DROP TRIGGER fail_pending")
5197                .unwrap();
5198            client
5199                .apply_commit_frame(&["tasks".into()], &[change], None)
5200                .unwrap();
5201            assert_eq!(client.local_revision(), revision + 1);
5202            assert_eq!(client.pending_commit_ids(), vec![id]);
5203            // The overlay keeps the pending write visible; the row now carries
5204            // the server row's version it is layered on (§7.1).
5205            let mut expected = before[0].clone();
5206            expected.insert("_syncular_version".to_owned(), Value::from(2));
5207            assert_eq!(
5208                client.query("SELECT * FROM tasks", &[]).unwrap(),
5209                vec![expected]
5210            );
5211            assert_eq!(
5212                client
5213                    .conn
5214                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
5215                        .get::<_, String>(0))
5216                    .unwrap(),
5217                "remote"
5218            );
5219        }
5220    }
5221
5222    #[test]
5223    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
5224        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
5225            "columns": [
5226                {"name":"id", "type":"string", "nullable":false},
5227                {"name":"project_id", "type":"string", "nullable":false},
5228                {"name":"title", "type":"string", "nullable":false}],
5229            "scopes": [{"pattern":"project:{project_id}"}],
5230            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
5231            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
5232        let mut incremental =
5233            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5234        let mut reference =
5235            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5236        for instance in [&mut incremental, &mut reference] {
5237            for index in 0..16 {
5238                instance
5239                    .write_base_row(
5240                        "tasks",
5241                        &vec![
5242                            Some(ColumnValue::String(format!("task{index}"))),
5243                            Some(ColumnValue::String("p0".to_owned())),
5244                            Some(ColumnValue::String(format!("base{index}"))),
5245                        ],
5246                        1,
5247                    )
5248                    .unwrap();
5249            }
5250            instance
5251                .rebuild_overlay_if_dirty()
5252                .expect("rebuild overlay");
5253            instance.overlay_rebuild_count.set(0);
5254        }
5255        for index in 0..100 {
5256            let mutations = (0..1 + index % 4)
5257                .map(|offset| {
5258                    let id = format!("task{}", (index + offset) % 24);
5259                    if (index + offset) % 7 == 0 {
5260                        Mutation::Delete {
5261                            table: "tasks".to_owned(),
5262                            row_id: id,
5263                            base_version: None,
5264                        }
5265                    } else {
5266                        Mutation::Upsert {
5267                            table: "tasks".to_owned(),
5268                            values: Map::from_iter([
5269                                ("id".to_owned(), Value::from(id)),
5270                                (
5271                                    "project_id".to_owned(),
5272                                    Value::from(format!("p{}", index % 2)),
5273                                ),
5274                                (
5275                                    "title".to_owned(),
5276                                    Value::from(format!("needle{}", (index + offset) % 24)),
5277                                ),
5278                            ]),
5279                            base_version: None,
5280                        }
5281                    }
5282                })
5283                .collect::<Vec<_>>();
5284            reference.overlay_dirty.set(true);
5285            incremental.mutate(mutations.clone()).unwrap();
5286            reference.mutate(mutations).unwrap();
5287            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
5288                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
5289                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
5290            }
5291            assert_eq!(incremental.local_revision(), reference.local_revision());
5292            assert_eq!(
5293                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
5294                serde_json::to_value(reference.drain_change_batches()).unwrap()
5295            );
5296        }
5297        assert_eq!(incremental.outbox.len(), 100);
5298        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
5299        assert_eq!(reference.overlay_rebuild_count.get(), 100);
5300    }
5301
5302    #[test]
5303    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
5304        for fail_at in ["outbox", "revision"] {
5305            let mut client = client();
5306            let values = Map::from_iter([
5307                ("id".to_owned(), Value::from("t1")),
5308                ("project_id".to_owned(), Value::from("p1")),
5309            ]);
5310            let mutation = Mutation::Upsert {
5311                table: "tasks".to_owned(),
5312                values,
5313                base_version: None,
5314            };
5315            let trigger = if fail_at == "outbox" {
5316                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
5317            } else {
5318                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
5319            };
5320            client.conn.execute_batch(&trigger).unwrap();
5321            let revision = client.local_revision();
5322            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
5323            assert_eq!(client.local_revision(), revision);
5324            assert!(client.outbox.is_empty());
5325            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5326            assert_eq!(
5327                client
5328                    .conn
5329                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5330                        .get::<_, i64>(0))
5331                    .unwrap(),
5332                0
5333            );
5334            assert!(client.drain_change_batches().is_empty());
5335            assert!(!client.overlay_dirty.get());
5336            client
5337                .conn
5338                .execute_batch("DROP TRIGGER fail_append")
5339                .unwrap();
5340            client.mutate(vec![mutation]).unwrap();
5341            assert_eq!(client.outbox.len(), 1);
5342            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
5343        }
5344    }
5345
5346    #[test]
5347    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
5348        let mut client = client();
5349        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
5350        let values = Map::from_iter([
5351            ("id".to_owned(), Value::from("t1")),
5352            ("project_id".to_owned(), Value::from("p1")),
5353        ]);
5354        let payload = encode_row_json(
5355            client.schema.table("tasks").unwrap(),
5356            "t1",
5357            &values,
5358            &client.encryption,
5359        )
5360        .unwrap();
5361        let first = ssp2::model::Change {
5362            table_index: 0,
5363            row_id: "t1".to_owned(),
5364            op: Op::Upsert,
5365            row_version: Some(1),
5366            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
5367            row: Some(payload),
5368        };
5369        let mut invalid = first.clone();
5370        invalid.table_index = 1;
5371        let revision = client.local_revision();
5372        client.begin_observation("failed_mirror").unwrap();
5373        assert!(client
5374            .apply_commit_changes(&tables, &[first, invalid])
5375            .is_err());
5376        client.rollback_observation("failed_mirror");
5377        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
5378        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5379        assert_eq!(
5380            client
5381                .conn
5382                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
5383                    .get::<_, i64>(
5384                    0
5385                ))
5386                .unwrap(),
5387            0
5388        );
5389        assert_eq!(client.local_revision(), revision);
5390        assert!(client.drain_change_batches().is_empty());
5391    }
5392
5393    #[test]
5394    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
5395        for failure in ["journal", "revision", "commit", "later-frame"] {
5396            let mut client = client();
5397            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5398            for id in ["first", "second"] {
5399                client
5400                    .mutate(vec![Mutation::Upsert {
5401                        table: "tasks".to_owned(),
5402                        values: Map::from_iter([
5403                            ("id".to_owned(), Value::from(id)),
5404                            ("project_id".to_owned(), Value::from("p1")),
5405                        ]),
5406                        base_version: None,
5407                    }])
5408                    .unwrap();
5409            }
5410            let (_, meta) = client.build_request(false).unwrap();
5411            // A malicious or stale response must not drain an unsent local commit.
5412            client
5413                .mutate(vec![Mutation::Upsert {
5414                    table: "tasks".to_owned(),
5415                    values: Map::from_iter([
5416                        ("id".to_owned(), Value::from("later")),
5417                        ("project_id".to_owned(), Value::from("p1")),
5418                    ]),
5419                    base_version: None,
5420                }])
5421                .unwrap();
5422            let ids = client.pending_commit_ids();
5423            let rows = client
5424                .query("SELECT * FROM tasks ORDER BY id", &[])
5425                .unwrap();
5426            let revision = client.local_revision();
5427            client.drain_change_batches();
5428            if failure != "later-frame" {
5429                client.conn.execute_batch(match failure {
5430                    "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",
5431                    "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",
5432                    _ => "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",
5433                }).unwrap();
5434            }
5435            let mut response = Message {
5436                wire_version: WIRE_VERSION,
5437                msg_kind: MsgKind::Response,
5438                frames: vec![Frame::RespHeader {
5439                    required_schema_version: None,
5440                    latest_schema_version: None,
5441                    log_epoch: Some("epoch-1".to_owned()),
5442                    reset_required: Some(false),
5443                }],
5444            };
5445            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
5446                response.frames.push(Frame::PushResult {
5447                    client_commit_id: id.to_owned(),
5448                    status: PushStatus::Applied,
5449                    commit_seq: Some(1),
5450                    results: vec![OpResult::Applied { op_index: 0 }],
5451                });
5452            }
5453            if failure == "later-frame" {
5454                response.frames.push(Frame::Error {
5455                    code: "sync.invalid_request".to_owned(),
5456                    message: "later error".to_owned(),
5457                    category: "protocol".to_owned(),
5458                    retryable: false,
5459                    recommended_action: "retry".to_owned(),
5460                    details: None,
5461                });
5462            }
5463            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5464            let outcome = client.process_response(&mut transport, response.clone(), &meta);
5465            // §1.4 rule 4: the deferred foreign key of "commit" fails the
5466            // response's coalesced COMMIT instead of the run's RELEASE.
5467            let expected = match failure {
5468                "later-frame" => "sync.invalid_request",
5469                "commit" => "storage.failed",
5470                _ => "client.outcome_persistence_failed",
5471            };
5472            assert!(
5473                matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5474                "{failure}: {outcome:?}"
5475            );
5476            if failure == "later-frame" {
5477                assert_eq!(client.pending_commit_ids(), ids[2..]);
5478                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5479                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
5480                let batches = client.drain_change_batches();
5481                assert_eq!(batches.len(), 1);
5482                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
5483                assert_eq!(client.local_revision(), revision + 1);
5484                response.frames.pop();
5485            } else {
5486                assert_eq!(client.pending_commit_ids(), ids);
5487                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5488                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
5489                assert_eq!(client.local_revision(), revision);
5490                assert!(client.drain_change_batches().is_empty());
5491                assert_eq!(
5492                    client
5493                        .query("SELECT * FROM tasks ORDER BY id", &[])
5494                        .unwrap(),
5495                    rows
5496                );
5497                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
5498            }
5499            for frame in &mut response.frames {
5500                if let Frame::PushResult { status, .. } = frame {
5501                    *status = PushStatus::Cached;
5502                }
5503            }
5504            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
5505            else {
5506                panic!("retry failed");
5507            };
5508            assert_eq!(
5509                report.applied,
5510                if failure == "later-frame" {
5511                    vec![]
5512                } else {
5513                    ids[..2].to_vec()
5514                }
5515            );
5516            assert_eq!(client.pending_commit_ids(), ids[2..]);
5517            let batches = client.drain_change_batches();
5518            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
5519            if let Some(batch) = batches.first() {
5520                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
5521            }
5522            assert_eq!(client.local_revision(), revision + 1);
5523        }
5524    }
5525
5526    #[test]
5527    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
5528        for failure in ["revision", "journal", "commit"] {
5529            for status in ["applied", "cached", "rejected", "conflict"] {
5530                let mut client = client();
5531                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5532                for id in ["first", "later"] {
5533                    client
5534                        .mutate(vec![Mutation::Upsert {
5535                            table: "tasks".to_owned(),
5536                            values: Map::from_iter([
5537                                ("id".to_owned(), Value::from(id)),
5538                                ("project_id".to_owned(), Value::from("p1")),
5539                            ]),
5540                            base_version: None,
5541                        }])
5542                        .unwrap();
5543                }
5544                let (_, meta) = client.build_request(false).unwrap();
5545                let ids = client.pending_commit_ids();
5546                let rows = client
5547                    .query("SELECT * FROM tasks ORDER BY id", &[])
5548                    .unwrap();
5549                let revision = client.local_revision();
5550                client.drain_change_batches();
5551                let (push_status, results) = match status {
5552                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
5553                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
5554                    "conflict" => (
5555                        PushStatus::Rejected,
5556                        vec![OpResult::Conflict {
5557                            op_index: 0,
5558                            code: "sync.version_conflict".to_owned(),
5559                            message: "conflict".to_owned(),
5560                            server_version: 1,
5561                            server_row: encode_row_json(
5562                                client.schema.table("tasks").unwrap(),
5563                                "first",
5564                                &Map::from_iter([
5565                                    ("id".to_owned(), Value::from("first")),
5566                                    ("project_id".to_owned(), Value::from("p1")),
5567                                ]),
5568                                &client.encryption,
5569                            )
5570                            .unwrap(),
5571                            conflict_columns: Vec::new(),
5572                        }],
5573                    ),
5574                    _ => (
5575                        PushStatus::Rejected,
5576                        vec![OpResult::Error {
5577                            op_index: 0,
5578                            code: "sync.validation_failed".to_owned(),
5579                            message: "rejected".to_owned(),
5580                            retryable: false,
5581                        }],
5582                    ),
5583                };
5584                let response = Message {
5585                    wire_version: WIRE_VERSION,
5586                    msg_kind: MsgKind::Response,
5587                    frames: vec![
5588                        Frame::RespHeader {
5589                            required_schema_version: None,
5590                            latest_schema_version: None,
5591                            log_epoch: Some("epoch-1".to_owned()),
5592                            reset_required: Some(false),
5593                        },
5594                        Frame::PushResult {
5595                            client_commit_id: ids[0].clone(),
5596                            status: push_status,
5597                            commit_seq: Some(1),
5598                            results,
5599                        },
5600                    ],
5601                };
5602                client.conn.execute_batch(match failure {
5603                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
5604                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
5605                    _ => "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",
5606                }).unwrap();
5607                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5608                let outcome = client.process_response(&mut transport, response.clone(), &meta);
5609                assert_eq!(
5610                    client.pending_commit_ids(),
5611                    ids,
5612                    "{failure}/{status}: pending identity"
5613                );
5614                assert_eq!(
5615                    client.local_revision(),
5616                    revision,
5617                    "{failure}/{status}: revision"
5618                );
5619                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5620                assert!(client.conflicts().is_empty());
5621                assert!(client.rejections().is_empty());
5622                assert!(client.drain_change_batches().is_empty());
5623                assert_eq!(
5624                    client
5625                        .query("SELECT * FROM tasks ORDER BY id", &[])
5626                        .unwrap(),
5627                    rows
5628                );
5629                // §1.4 rule 4: the deferred foreign key of "commit" fails the
5630                // response's coalesced COMMIT instead of the result's RELEASE.
5631                let expected = if failure == "commit" {
5632                    "storage.failed"
5633                } else {
5634                    "client.outcome_persistence_failed"
5635                };
5636                assert!(
5637                    matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5638                    "{failure}/{status}: failed round"
5639                );
5640                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
5641                assert!(matches!(
5642                    client.process_response(&mut transport, response, &meta),
5643                    SyncOutcome::Ok(_)
5644                ));
5645                assert_eq!(client.pending_commit_ids(), ids[1..]);
5646                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5647                assert_eq!(client.drain_change_batches().len(), 1);
5648            }
5649        }
5650    }
5651
5652    #[test]
5653    fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
5654        let mut client = client();
5655        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5656        for index in 0..4 {
5657            client
5658                .mutate(vec![Mutation::Upsert {
5659                    table: "tasks".to_owned(),
5660                    values: Map::from_iter([
5661                        ("id".to_owned(), Value::from(format!("task-{index}"))),
5662                        ("project_id".to_owned(), Value::from("project-1")),
5663                    ]),
5664                    base_version: None,
5665                }])
5666                .expect("queue commit");
5667        }
5668
5669        let (_, request_meta) = client.build_request(false).unwrap();
5670        let ids = &request_meta.pushed_ids;
5671        assert_eq!(ids.len(), 4);
5672        let response = Message {
5673            wire_version: WIRE_VERSION,
5674            msg_kind: MsgKind::Response,
5675            frames: vec![
5676                Frame::RespHeader {
5677                    required_schema_version: None,
5678                    latest_schema_version: None,
5679                    log_epoch: Some("epoch-1".to_owned()),
5680                    reset_required: Some(false),
5681                },
5682                Frame::PushResult {
5683                    client_commit_id: ids[0].clone(),
5684                    status: PushStatus::Applied,
5685                    commit_seq: Some(1),
5686                    results: vec![OpResult::Applied { op_index: 0 }],
5687                },
5688                Frame::PushResult {
5689                    client_commit_id: ids[1].clone(),
5690                    status: PushStatus::Cached,
5691                    commit_seq: Some(2),
5692                    results: vec![OpResult::Applied { op_index: 0 }],
5693                },
5694                Frame::PushResult {
5695                    client_commit_id: ids[2].clone(),
5696                    status: PushStatus::Rejected,
5697                    commit_seq: None,
5698                    results: vec![OpResult::Error {
5699                        op_index: 0,
5700                        code: "sync.validation_failed".to_owned(),
5701                        message: "rejected".to_owned(),
5702                        retryable: false,
5703                    }],
5704                },
5705                Frame::PushResult {
5706                    client_commit_id: ids[3].clone(),
5707                    status: PushStatus::Rejected,
5708                    commit_seq: None,
5709                    results: vec![OpResult::Error {
5710                        op_index: 0,
5711                        code: "sync.idempotency_cache_miss".to_owned(),
5712                        message: "retry".to_owned(),
5713                        retryable: true,
5714                    }],
5715                },
5716            ],
5717        };
5718        let mut transport =
5719            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
5720
5721        client.overlay_rebuild_count.set(0);
5722        client.outcome_prune_count.set(0);
5723        let outcome = client.process_response(&mut transport, response, &request_meta);
5724        let SyncOutcome::Ok(report) = outcome else {
5725            panic!("mixed push-result response failed");
5726        };
5727
5728        assert_eq!(report.applied, ids[..2]);
5729        assert_eq!(report.rejected, ids[2..3]);
5730        assert_eq!(report.retryable, ids[3..4]);
5731        assert_eq!(client.pending_commit_ids(), ids[3..4]);
5732        assert_eq!(
5733            client
5734                .query("SELECT id FROM tasks ORDER BY id", &[])
5735                .expect("query visible overlay"),
5736            ["task-0", "task-1", "task-3"]
5737                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
5738                .to_vec()
5739        );
5740        // The successful ACK run and final rejection have distinct durable boundaries.
5741        assert_eq!(client.overlay_rebuild_count.get(), 2);
5742        assert_eq!(client.outcome_prune_count.get(), 1);
5743    }
5744
5745    fn replay_schema(fts: bool) -> Value {
5746        json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
5747            {"name":"id","type":"string","nullable":false},
5748            {"name":"project_id","type":"string","nullable":false},
5749            {"name":"title","type":"string","nullable":false},
5750            {"name":"done","type":"boolean","nullable":false}
5751        ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5752    }
5753
5754    #[test]
5755    fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5756        for schema_reset in [false, true] {
5757            let mut schema = replay_schema(false);
5758            let mut client =
5759                SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5760            let id = client
5761                .mutate(vec![Mutation::Upsert {
5762                    table: "tasks".into(),
5763                    values: Map::from_iter([
5764                        ("id".into(), json!("queued")),
5765                        ("project_id".into(), json!("p1")),
5766                        ("title".into(), json!("old")),
5767                        ("done".into(), json!(false)),
5768                    ]),
5769                    base_version: None,
5770                }])
5771                .unwrap();
5772            client.set_meta(LOG_EPOCH_KEY, "old");
5773            let revision = client.local_revision();
5774            let needed = client.sync_needed;
5775            client.drain_change_batches();
5776            client.drain_sync_intents();
5777            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();
5778            if schema_reset {
5779                schema["version"] = json!(2);
5780                schema["tables"][0]["columns"]
5781                    .as_array_mut()
5782                    .unwrap()
5783                    .retain(|column| column["name"] != "title");
5784                client.schema = parse_schema_json(&schema).unwrap();
5785                assert!(client.run_schema_reset().is_err());
5786            } else {
5787                assert!(client.run_log_epoch_reset("new").is_err());
5788            }
5789            assert!(!client.upgrading);
5790            assert_eq!(client.sync_needed, needed);
5791            assert_eq!(client.local_revision(), revision);
5792            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5793            assert!(client.rejections.is_empty());
5794            assert!(client.commit_outcome(&id).unwrap().is_none());
5795            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5796            assert_eq!(
5797                client
5798                    .query("SELECT title FROM tasks WHERE id='queued'", &[])
5799                    .unwrap()[0]["title"],
5800                "old"
5801            );
5802            assert_eq!(
5803                client
5804                    .conn
5805                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5806                        .get::<_, i64>(0))
5807                    .unwrap(),
5808                1
5809            );
5810            assert!(client.drain_change_batches().is_empty());
5811            assert!(client.drain_sync_intents().is_empty());
5812            assert!(client.conn.is_autocommit());
5813        }
5814    }
5815
5816    #[test]
5817    fn cached_blob_cleanup_propagates_delete_failures() {
5818        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5819            {"name":"id","type":"string","nullable":false},
5820            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5821        let mut client =
5822            SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5823        client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5824        client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5825        client
5826            .subscribe("attachments".into(), "attachments".into(), vec![], None)
5827            .unwrap();
5828        client.subs[0].effective = Some(vec![]);
5829        client.persist_sub(&client.subs[0]).unwrap();
5830        let revision = client.local_revision();
5831        let (_, meta) = client.build_request(false).unwrap();
5832        let mut transport = CountingRealtimeTransport::default();
5833        let mut report = SyncReport::default();
5834        assert!(client
5835            .process_section(
5836                &mut transport,
5837                "attachments",
5838                SubStatus::Revoked,
5839                "sync.scope_revoked",
5840                vec![],
5841                vec![],
5842                Some((0, None)),
5843                &meta,
5844                &mut report,
5845                &HashSet::new()
5846            )
5847            .is_err());
5848        assert_eq!(client.subs[0].state, SubState::Active);
5849        assert_eq!(client.local_revision(), revision);
5850        assert!(report.revoked.is_empty());
5851        assert!(client.conn.is_autocommit());
5852        client
5853            .conn
5854            .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5855            .unwrap();
5856        assert!(client
5857            .process_section(
5858                &mut transport,
5859                "attachments",
5860                SubStatus::Revoked,
5861                "sync.scope_revoked",
5862                vec![],
5863                vec![],
5864                Some((0, None)),
5865                &meta,
5866                &mut report,
5867                &HashSet::new()
5868            )
5869            .is_ok());
5870        assert_eq!(client.subs[0].state, SubState::Revoked);
5871        assert_eq!(
5872            client
5873                .conn
5874                .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5875                    .get::<_, i64>(0))
5876                .unwrap(),
5877            0
5878        );
5879    }
5880
5881    #[test]
5882    fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5883        let path =
5884            std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5885        let schema = replay_schema(false);
5886        let mut client = SyncClient::open_path(
5887            "replay".into(),
5888            &schema,
5889            ClientLimits::default(),
5890            path.to_str().unwrap(),
5891        )
5892        .unwrap();
5893        let ids = ["early", "bad"].map(|id| {
5894            client
5895                .mutate(vec![Mutation::Upsert {
5896                    table: "tasks".into(),
5897                    values: Map::from_iter([
5898                        ("id".into(), json!(id)),
5899                        ("project_id".into(), json!("p1")),
5900                        ("title".into(), json!(id)),
5901                        ("done".into(), json!(false)),
5902                    ]),
5903                    base_version: None,
5904                }])
5905                .unwrap()
5906        });
5907        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();
5908        let revision = client.local_revision();
5909        drop(client);
5910        let error = SyncClient::open_path(
5911            "replay".into(),
5912            &schema,
5913            ClientLimits::default(),
5914            path.to_str().unwrap(),
5915        )
5916        .err()
5917        .expect("reopen must fail");
5918        assert!(error.contains("replay failed"));
5919        let conn = Connection::open(&path).unwrap();
5920        assert_eq!(
5921            conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5922                .unwrap(),
5923            0
5924        );
5925        assert_eq!(
5926            conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5927                .get::<_, i64>(0))
5928                .unwrap(),
5929            2
5930        );
5931        assert_eq!(
5932            meta_get(&conn, LOCAL_REVISION_KEY),
5933            Some(revision.to_string())
5934        );
5935        conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5936        drop(conn);
5937        let reopened = SyncClient::open_path(
5938            "replay".into(),
5939            &schema,
5940            ClientLimits::default(),
5941            path.to_str().unwrap(),
5942        )
5943        .unwrap();
5944        assert_eq!(reopened.pending_commit_ids(), ids);
5945        assert_eq!(
5946            reopened
5947                .query("SELECT id FROM tasks ORDER BY id", &[])
5948                .unwrap()
5949                .len(),
5950            2
5951        );
5952        drop(reopened);
5953        std::fs::remove_file(path).unwrap();
5954    }
5955
5956    #[test]
5957    fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5958        let schema = replay_schema(false);
5959        let mut client =
5960            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5961        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5962        client
5963            .write_base_row(
5964                "tasks",
5965                &vec![
5966                    Some(ColumnValue::String("queued".into())),
5967                    Some(ColumnValue::String("p1".into())),
5968                    Some(ColumnValue::String("server".into())),
5969                    Some(ColumnValue::Boolean(false)),
5970                ],
5971                1,
5972            )
5973            .unwrap();
5974        client.rebuild_overlay().unwrap();
5975        let id = client
5976            .patch(
5977                "tasks",
5978                "queued",
5979                Map::from_iter([("title".into(), json!("local"))]),
5980                Some(1),
5981            )
5982            .unwrap();
5983        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5984        let base = client
5985            .query("SELECT * FROM _syncular_base_tasks", &[])
5986            .unwrap();
5987        let revision = client.local_revision();
5988        client.drain_change_batches();
5989        client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5990        let remote = vec![
5991            Some(ColumnValue::String("queued".into())),
5992            Some(ColumnValue::String("p1".into())),
5993            Some(ColumnValue::String("remote".into())),
5994            Some(ColumnValue::Boolean(false)),
5995        ];
5996        let bytes = crate::values::encode_row_json(
5997            client.schema.table("tasks").unwrap(),
5998            "queued",
5999            &Map::from_iter(
6000                client
6001                    .schema
6002                    .table("tasks")
6003                    .unwrap()
6004                    .columns
6005                    .iter()
6006                    .zip(&remote)
6007                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
6008            ),
6009            &client.encryption,
6010        )
6011        .unwrap();
6012        let change = ssp2::model::Change {
6013            table_index: 0,
6014            op: Op::Upsert,
6015            row_id: "queued".into(),
6016            row_version: Some(2),
6017            scopes: vec![("project_id".into(), "p1".into())],
6018            row: Some(bytes),
6019        };
6020        assert!(client
6021            .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
6022            .is_err());
6023        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
6024        assert_eq!(
6025            client
6026                .query("SELECT * FROM _syncular_base_tasks", &[])
6027                .unwrap(),
6028            base
6029        );
6030        assert_eq!(client.local_revision(), revision);
6031        assert!(client.drain_change_batches().is_empty());
6032        let response = Message {
6033            wire_version: 2,
6034            msg_kind: MsgKind::Response,
6035            frames: vec![
6036                Frame::RespHeader {
6037                    required_schema_version: None,
6038                    latest_schema_version: None,
6039                    log_epoch: Some("epoch-1".into()),
6040                    reset_required: Some(false),
6041                },
6042                Frame::PushResult {
6043                    client_commit_id: id.clone(),
6044                    status: PushStatus::Applied,
6045                    commit_seq: Some(2),
6046                    results: vec![OpResult::Applied { op_index: 0 }],
6047                },
6048            ],
6049        };
6050        let (_, meta) = client.build_request(false).unwrap();
6051        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
6052        assert!(
6053            matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
6054        );
6055        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6056        assert!(client.commit_outcome(&id).unwrap().is_none());
6057        assert_eq!(
6058            client
6059                .conn
6060                .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
6061                    .get::<_, i64>(
6062                    0
6063                ))
6064                .unwrap(),
6065            0
6066        );
6067        assert_eq!(
6068            client
6069                .conn
6070                .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
6071                    row.get::<_, i64>(0)
6072                })
6073                .unwrap(),
6074            0
6075        );
6076        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
6077        assert_eq!(client.local_revision(), revision);
6078        assert!(client.drain_change_batches().is_empty());
6079        client
6080            .conn
6081            .execute_batch("DROP TRIGGER fail_replay")
6082            .unwrap();
6083        assert!(matches!(
6084            client.process_response(&mut transport, response, &meta),
6085            SyncOutcome::Ok(_)
6086        ));
6087        client
6088            .apply_commit_frame(&["tasks".into()], &[change], Some(2))
6089            .unwrap();
6090        assert!(client.pending_commit_ids().is_empty());
6091        assert_eq!(
6092            client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
6093            json!("remote")
6094        );
6095    }
6096
6097    #[test]
6098    fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
6099        let client = SyncClient::new(
6100            "replay".into(),
6101            &replay_schema(false),
6102            ClientLimits::default(),
6103        )
6104        .unwrap();
6105        let table = client.schema.table("tasks").unwrap();
6106        let patch = OutboxOp {
6107            table: "tasks".into(),
6108            row_id: "absent".into(),
6109            upsert: true,
6110            insert: false,
6111            base_version: Some(1),
6112            values: Some(Map::from_iter([
6113                ("id".into(), json!("absent")),
6114                ("title".into(), json!("patch")),
6115            ])),
6116        };
6117        assert!(client.visible_row(table, "absent").unwrap().is_none());
6118        client
6119            .apply_outbox_ops([&patch])
6120            .expect("sparse replay over truly absent row");
6121        client
6122            .conn
6123            .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
6124            .unwrap();
6125        assert!(client
6126            .visible_row(table, "bad")
6127            .unwrap_err()
6128            .starts_with("sync.local_corrupt:"));
6129        let mut invalid = patch.clone();
6130        invalid.values = Some(Map::from_iter([
6131            ("id".into(), json!("bad-value")),
6132            ("project_id".into(), json!("p1")),
6133            ("title".into(), json!("title")),
6134            ("done".into(), json!({"bad":true})),
6135        ]));
6136        assert!(client
6137            .apply_outbox_ops([&invalid])
6138            .unwrap_err()
6139            .starts_with("sync.local_corrupt:"));
6140        client.conn.execute_batch("DROP TABLE tasks").unwrap();
6141        assert!(
6142            client.apply_outbox_ops([&patch]).is_err(),
6143            "SQL failure must not count as absence"
6144        );
6145    }
6146
6147    #[test]
6148    fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
6149        let mut schema = replay_schema(false);
6150        schema["tables"][0]["indexes"] =
6151            json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
6152        let mut client =
6153            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
6154        let id = client
6155            .mutate(vec![Mutation::Upsert {
6156                table: "tasks".into(),
6157                values: Map::from_iter([
6158                    ("id".into(), json!("queued")),
6159                    ("project_id".into(), json!("p1")),
6160                    ("title".into(), json!("occupied")),
6161                    ("done".into(), json!(false)),
6162                ]),
6163                base_version: None,
6164            }])
6165            .unwrap();
6166        client
6167            .write_base_row(
6168                "tasks",
6169                &vec![
6170                    Some(ColumnValue::String("server".into())),
6171                    Some(ColumnValue::String("p1".into())),
6172                    Some(ColumnValue::String("occupied".into())),
6173                    Some(ColumnValue::Boolean(false)),
6174                ],
6175                1,
6176            )
6177            .unwrap();
6178        client.rebuild_overlay_if_dirty().unwrap();
6179        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6180        assert!(client
6181            .visible_row(client.schema.table("tasks").unwrap(), "queued")
6182            .unwrap()
6183            .is_none());
6184        assert!(client.rejections().is_empty());
6185        client.delete_base_row("tasks", "server").unwrap();
6186        client.rebuild_overlay_if_dirty().unwrap();
6187        assert!(client
6188            .visible_row(client.schema.table("tasks").unwrap(), "queued")
6189            .unwrap()
6190            .is_some());
6191        client
6192            .write_base_row(
6193                "tasks",
6194                &vec![
6195                    Some(ColumnValue::String("base".into())),
6196                    Some(ColumnValue::String("p1".into())),
6197                    Some(ColumnValue::String("base-title".into())),
6198                    Some(ColumnValue::Boolean(false)),
6199                ],
6200                1,
6201            )
6202            .unwrap();
6203        client.conn.execute_batch("DROP TABLE tasks").unwrap();
6204        client
6205            .retain_failed_commit(
6206                "retained",
6207                &[OutboxOp {
6208                    table: "tasks".into(),
6209                    row_id: "base".into(),
6210                    upsert: true,
6211                    insert: false,
6212                    base_version: Some(1),
6213                    values: Some(Map::from_iter([
6214                        ("id".into(), json!("base")),
6215                        ("title".into(), json!("patch")),
6216                    ])),
6217                }],
6218            )
6219            .expect("an existing base must not read the unavailable visible table");
6220    }
6221
6222    #[test]
6223    fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
6224        for failure in ["fts", "release"] {
6225            let mut client = SyncClient::new(
6226                "replay".into(),
6227                &replay_schema(true),
6228                ClientLimits::default(),
6229            )
6230            .unwrap();
6231            client
6232                .mutate(vec![Mutation::Upsert {
6233                    table: "tasks".into(),
6234                    values: Map::from_iter([
6235                        ("id".into(), json!("queued")),
6236                        ("project_id".into(), json!("p1")),
6237                        ("title".into(), json!("needle")),
6238                        ("done".into(), json!(false)),
6239                    ]),
6240                    base_version: None,
6241                }])
6242                .unwrap();
6243            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
6244            let fts_before = client
6245                .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6246                .unwrap();
6247            let triggers_before: Vec<String> = client
6248                .conn
6249                .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
6250                .unwrap()
6251                .query_map([], |row| row.get(0))
6252                .unwrap()
6253                .collect::<Result<_, _>>()
6254                .unwrap();
6255            if failure == "fts" {
6256                client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
6257            } else {
6258                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();
6259            }
6260            client.overlay_dirty.set(true);
6261            assert!(client.rebuild_overlay().is_err());
6262            assert!(client.overlay_dirty.get());
6263            assert!(client.conn.is_autocommit());
6264            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
6265            assert_eq!(
6266                client
6267                    .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6268                    .unwrap(),
6269                fts_before
6270            );
6271            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();
6272            assert_eq!(original_triggers, triggers_before);
6273            assert_eq!(client.pending_commit_ids().len(), 1);
6274            client
6275                .conn
6276                .execute_batch(if failure == "fts" {
6277                    "DROP TRIGGER fail_fts"
6278                } else {
6279                    "DROP TRIGGER fail_release"
6280                })
6281                .unwrap();
6282            client
6283                .rebuild_overlay()
6284                .expect("retry after removing the fault");
6285        }
6286    }
6287
6288    #[test]
6289    fn secondary_unique_collision_preserves_existing_synced_row() {
6290        let client = SyncClient::new(
6291            "unique-upsert-test".to_owned(),
6292            &json!({
6293                "version": 1,
6294                "tables": [{
6295                    "name": "tasks",
6296                    "primaryKey": "id",
6297                    "columns": [
6298                        { "name": "id", "type": "string", "nullable": false },
6299                        { "name": "project_id", "type": "string", "nullable": false },
6300                        { "name": "title", "type": "string", "nullable": false }
6301                    ],
6302                    "scopes": [{ "pattern": "project:{project_id}" }],
6303                    "indexes": [{
6304                        "name": "tasks_by_project_title",
6305                        "columns": ["project_id", "title"],
6306                        "unique": true
6307                    }]
6308                }]
6309            }),
6310            ClientLimits::default(),
6311        )
6312        .expect("test client");
6313        let table = client.schema.table("tasks").expect("tasks table");
6314        let sql = client.insert_row_sql(&base_table("tasks"), table);
6315
6316        client
6317            .conn
6318            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
6319            .expect("insert first row");
6320        client
6321            .conn
6322            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
6323            .expect("update same primary key");
6324        client
6325            .conn
6326            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
6327            .expect("insert second row");
6328        assert!(client
6329            .conn
6330            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
6331            .is_err());
6332
6333        let rows = client
6334            .conn
6335            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
6336            .expect("prepare rows")
6337            .query_map([], |row| {
6338                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6339            })
6340            .expect("query rows")
6341            .collect::<Result<Vec<_>, _>>()
6342            .expect("collect rows");
6343        assert_eq!(
6344            rows,
6345            vec![
6346                ("t1".to_owned(), "updated".to_owned()),
6347                ("t2".to_owned(), "original".to_owned())
6348            ]
6349        );
6350    }
6351
6352    #[test]
6353    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
6354        let path = std::env::temp_dir().join(format!(
6355            "syncular-startup-intent-{}.db",
6356            uuid::Uuid::new_v4()
6357        ));
6358        let schema = json!({
6359            "version": 1,
6360            "tables": [{
6361                "name": "tasks",
6362                "primaryKey": "id",
6363                "columns": [
6364                    { "name": "id", "type": "string", "nullable": false },
6365                    { "name": "project_id", "type": "string", "nullable": false }
6366                ],
6367                "scopes": [{ "pattern": "project:{project_id}" }]
6368            }]
6369        });
6370
6371        {
6372            let mut first = SyncClient::open_path_with_identity(
6373                None,
6374                &schema,
6375                ClientLimits::default(),
6376                path.to_str().expect("UTF-8 temp path"),
6377            )
6378            .expect("first open");
6379            first
6380                .set_window(
6381                    &WindowBase {
6382                        table: "tasks".to_owned(),
6383                        variable: "project_id".to_owned(),
6384                        fixed_scopes: Vec::new(),
6385                        params: None,
6386                    },
6387                    &["persisted".to_owned()],
6388                )
6389                .expect("persist window");
6390        }
6391
6392        let mut reopened = SyncClient::open_path_with_identity(
6393            None,
6394            &schema,
6395            ClientLimits::default(),
6396            path.to_str().expect("UTF-8 temp path"),
6397        )
6398        .expect("reopen");
6399        assert!(reopened.sync_needed());
6400        assert!(matches!(
6401            reopened.drain_sync_intents().as_slice(),
6402            [SyncIntent::Interactive]
6403        ));
6404        drop(reopened);
6405        std::fs::remove_file(path).expect("remove temp database");
6406    }
6407
6408    #[test]
6409    fn reopening_preserves_immutable_subscription_identity_and_progress() {
6410        let path = std::env::temp_dir().join(format!(
6411            "syncular-subscription-identity-{}.db",
6412            uuid::Uuid::new_v4()
6413        ));
6414        let schema = json!({
6415            "version": 1,
6416            "tables": [
6417                {
6418                    "name": "tasks",
6419                    "primaryKey": "id",
6420                    "columns": [
6421                        { "name": "id", "type": "string", "nullable": false },
6422                        { "name": "project_id", "type": "string", "nullable": false }
6423                    ],
6424                    "scopes": [{ "pattern": "project:{project_id}" }]
6425                },
6426                {
6427                    "name": "docs",
6428                    "primaryKey": "id",
6429                    "columns": [
6430                        { "name": "id", "type": "string", "nullable": false },
6431                        { "name": "org_id", "type": "string", "nullable": false },
6432                        { "name": "project_id", "type": "string", "nullable": false }
6433                    ],
6434                    "scopes": [
6435                        { "pattern": "org:{org_id}" },
6436                        { "pattern": "project:{projectId}", "column": "project_id" }
6437                    ]
6438                }
6439            ]
6440        });
6441
6442        {
6443            let mut first = SyncClient::open_path_with_identity(
6444                None,
6445                &schema,
6446                ClientLimits::default(),
6447                path.to_str().expect("UTF-8 temp path"),
6448            )
6449            .expect("first open");
6450            first
6451                .subscribe(
6452                    "stable-subscription".to_owned(),
6453                    "tasks".to_owned(),
6454                    vec![(
6455                        "project_id".to_owned(),
6456                        vec!["p2".to_owned(), "p1".to_owned()],
6457                    )],
6458                    Some(r#"{"view":"v1"}"#.to_owned()),
6459                )
6460                .expect("persist subscription");
6461            let persisted = {
6462                let subscription = first
6463                    .subs
6464                    .iter_mut()
6465                    .find(|subscription| subscription.id == "stable-subscription")
6466                    .expect("subscription");
6467                subscription.cursor = 41;
6468                subscription.bootstrap_state = Some("resume-token".to_owned());
6469                subscription.effective = Some(vec![(
6470                    "project_id".to_owned(),
6471                    vec!["p1".to_owned(), "p2".to_owned()],
6472                )]);
6473                subscription.synced_once = true;
6474                subscription.clone()
6475            };
6476            first.persist_sub(&persisted).unwrap();
6477        }
6478
6479        let mut reopened = SyncClient::open_path_with_identity(
6480            None,
6481            &schema,
6482            ClientLimits::default(),
6483            path.to_str().expect("UTF-8 temp path"),
6484        )
6485        .expect("reopen");
6486        let progress = reopened
6487            .subscription_state("stable-subscription")
6488            .expect("persisted state");
6489        assert_eq!(progress.cursor, 41);
6490        assert!(progress.has_resume_token);
6491
6492        reopened
6493            .subscribe(
6494                "stable-subscription".to_owned(),
6495                "tasks".to_owned(),
6496                vec![(
6497                    "project_id".to_owned(),
6498                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
6499                )],
6500                Some(r#"{"view":"v1"}"#.to_owned()),
6501            )
6502            .expect("canonical intent is idempotent");
6503        assert_eq!(
6504            reopened
6505                .subscription_state("stable-subscription")
6506                .expect("unchanged state")
6507                .cursor,
6508            progress.cursor
6509        );
6510
6511        for (table, scopes, params) in [
6512            (
6513                "tasks",
6514                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6515                Some(r#"{"view":"v1"}"#.to_owned()),
6516            ),
6517            (
6518                "tasks",
6519                vec![(
6520                    "project_id".to_owned(),
6521                    vec!["p2".to_owned(), "p1".to_owned()],
6522                )],
6523                Some(r#"{"view":"v2"}"#.to_owned()),
6524            ),
6525            (
6526                "docs",
6527                vec![
6528                    ("org_id".to_owned(), vec!["o1".to_owned()]),
6529                    ("projectId".to_owned(), vec!["p1".to_owned()]),
6530                ],
6531                Some(r#"{"view":"v1"}"#.to_owned()),
6532            ),
6533        ] {
6534            let error = reopened
6535                .subscribe(
6536                    "stable-subscription".to_owned(),
6537                    table.to_owned(),
6538                    scopes,
6539                    params,
6540                )
6541                .expect_err("identity rebind must fail");
6542            assert!(error.starts_with("client.subscription_intent_mismatch:"));
6543            assert_eq!(
6544                reopened
6545                    .subscription_state("stable-subscription")
6546                    .expect("unchanged state")
6547                    .cursor,
6548                progress.cursor
6549            );
6550        }
6551
6552        drop(reopened);
6553        std::fs::remove_file(path).expect("remove temp database");
6554    }
6555
6556    #[test]
6557    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
6558        let path = std::env::temp_dir().join(format!(
6559            "syncular-satisfied-schema-floor-{}.db",
6560            uuid::Uuid::new_v4()
6561        ));
6562        let schema = json!({
6563            "version": 23,
6564            "tables": [{
6565                "name": "tasks",
6566                "primaryKey": "id",
6567                "columns": [
6568                    { "name": "id", "type": "string", "nullable": false },
6569                    { "name": "project_id", "type": "string", "nullable": false }
6570                ],
6571                "scopes": [{ "pattern": "project:{project_id}" }]
6572            }]
6573        });
6574
6575        {
6576            let mut first = SyncClient::open_path_with_identity(
6577                None,
6578                &schema,
6579                ClientLimits::default(),
6580                path.to_str().expect("UTF-8 temp path"),
6581            )
6582            .expect("first open");
6583            first
6584                .subscribe(
6585                    "tasks".to_owned(),
6586                    "tasks".to_owned(),
6587                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6588                    None,
6589                )
6590                .expect("persist subscription");
6591            first.set_schema_floor(Some(SchemaFloor {
6592                required_schema_version: Some(22),
6593                latest_schema_version: Some(22),
6594            }));
6595        }
6596
6597        let mut reopened = SyncClient::open_path_with_identity(
6598            None,
6599            &schema,
6600            ClientLimits::default(),
6601            path.to_str().expect("UTF-8 temp path"),
6602        )
6603        .expect("reopen");
6604        assert!(reopened.schema_floor().is_none());
6605        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
6606        assert!(reopened.sync_needed());
6607        assert!(matches!(
6608            reopened.drain_sync_intents().as_slice(),
6609            [SyncIntent::Interactive]
6610        ));
6611        drop(reopened);
6612        std::fs::remove_file(path).expect("remove temp database");
6613    }
6614
6615    #[test]
6616    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
6617        let path = std::env::temp_dir().join(format!(
6618            "syncular-unsatisfied-schema-floor-{}.db",
6619            uuid::Uuid::new_v4()
6620        ));
6621        let schema = json!({
6622            "version": 1,
6623            "tables": [{
6624                "name": "tasks",
6625                "primaryKey": "id",
6626                "columns": [
6627                    { "name": "id", "type": "string", "nullable": false },
6628                    { "name": "project_id", "type": "string", "nullable": false }
6629                ],
6630                "scopes": [{ "pattern": "project:{project_id}" }]
6631            }]
6632        });
6633
6634        {
6635            let mut first = SyncClient::open_path_with_identity(
6636                None,
6637                &schema,
6638                ClientLimits::default(),
6639                path.to_str().expect("UTF-8 temp path"),
6640            )
6641            .expect("first open");
6642            first
6643                .subscribe(
6644                    "tasks".to_owned(),
6645                    "tasks".to_owned(),
6646                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6647                    None,
6648                )
6649                .expect("persist subscription");
6650            first.set_schema_floor(Some(SchemaFloor {
6651                required_schema_version: Some(2),
6652                latest_schema_version: Some(2),
6653            }));
6654        }
6655
6656        let reopened = SyncClient::open_path_with_identity(
6657            None,
6658            &schema,
6659            ClientLimits::default(),
6660            path.to_str().expect("UTF-8 temp path"),
6661        )
6662        .expect("reopen");
6663        assert_eq!(
6664            reopened.schema_floor(),
6665            Some(&SchemaFloor {
6666                required_schema_version: Some(2),
6667                latest_schema_version: Some(2),
6668            })
6669        );
6670        assert!(!reopened.sync_needed());
6671        drop(reopened);
6672        std::fs::remove_file(path).expect("remove temp database");
6673    }
6674
6675    #[test]
6676    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
6677        let path = std::env::temp_dir().join(format!(
6678            "syncular-indexed-column-bump-{}.db",
6679            uuid::Uuid::new_v4()
6680        ));
6681        let old_schema = json!({
6682            "version": 1,
6683            "tables": [
6684                {
6685                    "name": "tasks",
6686                    "primaryKey": "id",
6687                    "columns": [
6688                        { "name": "id", "type": "string", "nullable": false },
6689                        { "name": "project_id", "type": "string", "nullable": false }
6690                    ],
6691                    "scopes": [{ "pattern": "project:{project_id}" }]
6692                },
6693                {
6694                    "name": "legacy",
6695                    "primaryKey": "id",
6696                    "columns": [
6697                        { "name": "id", "type": "string", "nullable": false },
6698                        { "name": "project_id", "type": "string", "nullable": false }
6699                    ],
6700                    "scopes": [{ "pattern": "project:{project_id}" }]
6701                }
6702            ]
6703        });
6704        {
6705            let mut first = SyncClient::open_path_with_identity(
6706                None,
6707                &old_schema,
6708                ClientLimits::default(),
6709                path.to_str().expect("UTF-8 temp path"),
6710            )
6711            .expect("open old schema");
6712            first
6713                .subscribe(
6714                    "legacy-sub".to_owned(),
6715                    "legacy".to_owned(),
6716                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6717                    None,
6718                )
6719                .expect("persist legacy subscription");
6720        }
6721
6722        let new_schema = json!({
6723            "version": 2,
6724            "tables": [{
6725                "name": "tasks",
6726                "primaryKey": "id",
6727                "columns": [
6728                    { "name": "id", "type": "string", "nullable": false },
6729                    { "name": "project_id", "type": "string", "nullable": false },
6730                    { "name": "facility_membership_id", "type": "string", "nullable": true }
6731                ],
6732                "scopes": [{ "pattern": "project:{project_id}" }],
6733                "indexes": [{
6734                    "name": "tasks_by_membership",
6735                    "columns": ["project_id", "facility_membership_id"],
6736                    "unique": false
6737                }]
6738            }]
6739        });
6740        let upgraded = SyncClient::open_path_with_identity(
6741            None,
6742            &new_schema,
6743            ClientLimits::default(),
6744            path.to_str().expect("UTF-8 temp path"),
6745        )
6746        .expect("open upgraded schema");
6747        let columns = upgraded
6748            .conn
6749            .prepare("PRAGMA table_info(tasks)")
6750            .expect("prepare columns")
6751            .query_map([], |row| row.get::<_, String>(1))
6752            .expect("query columns")
6753            .collect::<Result<Vec<_>, _>>()
6754            .expect("collect columns");
6755        assert!(columns.contains(&"facility_membership_id".to_owned()));
6756        let index_count: i64 = upgraded
6757            .conn
6758            .query_row(
6759                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6760                [],
6761                |row| row.get(0),
6762            )
6763            .expect("query index");
6764        assert_eq!(index_count, 1);
6765        assert!(upgraded.subscription_state("legacy-sub").is_none());
6766        drop(upgraded);
6767        std::fs::remove_file(path).expect("remove temp database");
6768    }
6769
6770    #[test]
6771    fn migrates_pre_envelope_outcome_journal_additively() {
6772        let path = std::env::temp_dir().join(format!(
6773            "syncular-outcome-migration-{}.db",
6774            uuid::Uuid::new_v4()
6775        ));
6776        let conn = Connection::open(&path).expect("open legacy database");
6777        conn.execute_batch(
6778            "CREATE TABLE _syncular_commit_outcomes (
6779               seq INTEGER PRIMARY KEY AUTOINCREMENT,
6780               client_commit_id TEXT NOT NULL UNIQUE,
6781               status TEXT NOT NULL,
6782               recorded_at_ms INTEGER NOT NULL,
6783               results_json TEXT NOT NULL,
6784               resolution TEXT NOT NULL DEFAULT 'active',
6785               resolved_at_ms INTEGER,
6786               replacement_client_commit_id TEXT);",
6787        )
6788        .expect("create legacy outcome journal");
6789        drop(conn);
6790        let schema = json!({
6791            "version": 1,
6792            "tables": [{
6793                "name": "tasks",
6794                "primaryKey": "id",
6795                "columns": [
6796                    { "name": "id", "type": "string", "nullable": false },
6797                    { "name": "project_id", "type": "string", "nullable": false }
6798                ],
6799                "scopes": [{ "pattern": "project:{project_id}" }]
6800            }]
6801        });
6802        let client = SyncClient::open_path(
6803            "migration-native".to_owned(),
6804            &schema,
6805            ClientLimits::default(),
6806            path.to_str().expect("UTF-8 temp path"),
6807        )
6808        .expect("migrate database");
6809        let has_operations = client
6810            .conn
6811            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6812            .expect("prepare table info")
6813            .query_map([], |row| row.get::<_, String>(1))
6814            .expect("query table info")
6815            .filter_map(Result::ok)
6816            .any(|column| column == "operations_json");
6817        assert!(has_operations);
6818        drop(client);
6819        std::fs::remove_file(path).expect("remove temp database");
6820    }
6821
6822    #[test]
6823    fn retained_rejection_survives_file_reopen_and_security_purge() {
6824        let path =
6825            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6826        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6827            { "name": "id", "type": "string", "nullable": false },
6828            { "name": "project_id", "type": "string", "nullable": false },
6829            { "name": "title", "type": "string", "nullable": false }
6830        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6831        {
6832            let mut first = SyncClient::open_path(
6833                "retained-native".to_owned(),
6834                &schema,
6835                ClientLimits::default(),
6836                path.to_str().unwrap(),
6837            )
6838            .unwrap();
6839            first
6840                .write_base_row(
6841                    "tasks",
6842                    &vec![
6843                        Some(ColumnValue::String("t1".into())),
6844                        Some(ColumnValue::String("p1".into())),
6845                        Some(ColumnValue::String("server".into())),
6846                    ],
6847                    1,
6848                )
6849                .unwrap();
6850            first.rebuild_overlay().expect("rebuild overlay");
6851            let operation = OutboxOp {
6852                table: "tasks".into(),
6853                row_id: "t1".into(),
6854                upsert: true,
6855                insert: false,
6856                base_version: Some(1),
6857                values: Some(Map::from_iter([
6858                    ("id".into(), json!("t1")),
6859                    ("title".into(), json!("mine")),
6860                ])),
6861            };
6862            first
6863                .retain_failed_commit("retained", std::slice::from_ref(&operation))
6864                .unwrap();
6865            first
6866                .persist_commit_outcome(
6867                    "retained",
6868                    CommitOutcomeStatus::Rejected,
6869                    &[CommitOperationOutcome::Error {
6870                        rejection: RejectionRecord {
6871                            client_commit_id: "retained".into(),
6872                            op_index: 0,
6873                            code: "validation.denied".into(),
6874                            message: "denied".into(),
6875                            retryable: false,
6876                            details: None,
6877                            operation: None,
6878                        },
6879                    }],
6880                    Some(&[operation]),
6881                )
6882                .unwrap();
6883            first.rebuild_overlay().expect("rebuild overlay");
6884        }
6885        {
6886            let mut reopened = SyncClient::open_path(
6887                "retained-native".to_owned(),
6888                &schema,
6889                ClientLimits::default(),
6890                path.to_str().unwrap(),
6891            )
6892            .unwrap();
6893            let row = reopened
6894                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6895                .expect("read visible row")
6896                .unwrap();
6897            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6898            assert_eq!(
6899                reopened
6900                    .commit_outcome("retained")
6901                    .unwrap()
6902                    .unwrap()
6903                    .retained_rows
6904                    .unwrap()[0]
6905                    .server_row
6906                    .as_ref()
6907                    .unwrap()["title"],
6908                json!("server")
6909            );
6910            reopened
6911                .conn
6912                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6913                .unwrap();
6914            reopened.rebuild_overlay().expect("rebuild overlay");
6915            assert!(reopened
6916                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6917                .expect("read visible row")
6918                .is_none());
6919            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6920            assert!(retained.retained_rows.is_some());
6921            assert_eq!(
6922                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6923                json!("mine")
6924            );
6925            reopened
6926                .purge_local_data(&LocalDataPurgeInput {
6927                    purge_id: "protected-purge".into(),
6928                    targets: vec![LocalDataPurgeTarget {
6929                        table: "tasks".into(),
6930                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6931                    }],
6932                })
6933                .unwrap();
6934            assert!(reopened
6935                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6936                .expect("read visible row")
6937                .is_none());
6938            assert!(reopened
6939                .commit_outcome("retained")
6940                .unwrap()
6941                .unwrap()
6942                .retained_rows
6943                .is_none());
6944        }
6945        std::fs::remove_file(path).unwrap();
6946    }
6947
6948    #[test]
6949    fn durable_conflict_outcome_and_resolution_survive_reopen() {
6950        let path = std::env::temp_dir().join(format!(
6951            "syncular-durable-outcome-{}.db",
6952            uuid::Uuid::new_v4()
6953        ));
6954        let schema = json!({
6955            "version": 1,
6956            "tables": [{
6957                "name": "tasks",
6958                "primaryKey": "id",
6959                "columns": [
6960                    { "name": "id", "type": "string", "nullable": false },
6961                    { "name": "project_id", "type": "string", "nullable": false }
6962                ],
6963                "scopes": [{ "pattern": "project:{project_id}" }]
6964            }]
6965        });
6966        let conflict = ConflictRecord {
6967            client_commit_id: "losing-commit".to_owned(),
6968            op_index: 0,
6969            table: "tasks".to_owned(),
6970            row_id: "t1".to_owned(),
6971            code: "sync.version_conflict".to_owned(),
6972            message: "stale base version".to_owned(),
6973            server_version: 2,
6974            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6975            conflict_columns: vec!["project_id".to_owned()],
6976            operation: Some(CommitOperation {
6977                table: "tasks".to_owned(),
6978                row_id: "t1".to_owned(),
6979                op: "upsert".to_owned(),
6980                base_version: Some(1),
6981                values: Some(Map::from_iter([
6982                    ("id".into(), json!("t1")),
6983                    ("project_id".into(), json!("p1")),
6984                ])),
6985                insert: false,
6986            }),
6987        };
6988        let failed_operations = vec![
6989            OutboxOp {
6990                upsert: true,
6991                insert: false,
6992                table: "tasks".to_owned(),
6993                row_id: "t1".to_owned(),
6994                base_version: Some(1),
6995                values: Some(Map::from_iter([
6996                    ("id".into(), json!("t1")),
6997                    ("project_id".into(), json!("p1")),
6998                ])),
6999            },
7000            OutboxOp {
7001                upsert: true,
7002                insert: false,
7003                table: "tasks".to_owned(),
7004                row_id: "status-event-1".to_owned(),
7005                base_version: Some(0),
7006                values: Some(Map::from_iter([
7007                    ("id".into(), json!("status-event-1")),
7008                    ("project_id".into(), json!("p1")),
7009                ])),
7010            },
7011        ];
7012
7013        {
7014            let mut first = SyncClient::open_path(
7015                "durable-native".to_owned(),
7016                &schema,
7017                ClientLimits::default(),
7018                path.to_str().expect("UTF-8 temp path"),
7019            )
7020            .expect("first open");
7021            first
7022                .begin_observation("test_outcome")
7023                .expect("begin outcome");
7024            first
7025                .persist_commit_outcome(
7026                    "losing-commit",
7027                    CommitOutcomeStatus::Conflict,
7028                    &[CommitOperationOutcome::Conflict {
7029                        conflict: conflict.clone(),
7030                    }],
7031                    Some(&failed_operations),
7032                )
7033                .expect("persist outcome");
7034            first.conflicts.push(conflict);
7035            first
7036                .finish_observation(
7037                    "test_outcome",
7038                    ChangeAccumulator {
7039                        conflicts: true,
7040                        outcomes: true,
7041                        ..ChangeAccumulator::default()
7042                    },
7043                )
7044                .expect("commit outcome");
7045        }
7046
7047        {
7048            let mut reopened = SyncClient::open_path(
7049                "durable-native".to_owned(),
7050                &schema,
7051                ClientLimits::default(),
7052                path.to_str().expect("UTF-8 temp path"),
7053            )
7054            .expect("reopen");
7055            assert_eq!(reopened.conflicts().len(), 1);
7056            assert_eq!(
7057                reopened.conflicts()[0].conflict_columns,
7058                vec!["project_id".to_owned()],
7059                "conflictColumns survives the journal round trip"
7060            );
7061            let outcome = reopened
7062                .commit_outcome("losing-commit")
7063                .expect("read outcome")
7064                .expect("outcome");
7065            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7066            let operations = outcome.operations.expect("aggregate envelope");
7067            assert_eq!(operations.len(), 2);
7068            assert_eq!(operations[1].row_id, "status-event-1");
7069            let resolved = reopened
7070                .resolve_commit_outcome(ResolveCommitOutcomeInput {
7071                    client_commit_id: "losing-commit".to_owned(),
7072                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
7073                    replacement_client_commit_id: None,
7074                })
7075                .expect("resolve");
7076            assert_eq!(
7077                resolved.resolution,
7078                CommitOutcomeResolution::ResolvedKeepServer
7079            );
7080            assert!(reopened.conflicts().is_empty());
7081        }
7082
7083        let reopened = SyncClient::open_path(
7084            "durable-native".to_owned(),
7085            &schema,
7086            ClientLimits::default(),
7087            path.to_str().expect("UTF-8 temp path"),
7088        )
7089        .expect("second reopen");
7090        assert!(reopened.conflicts().is_empty());
7091        assert_eq!(
7092            reopened
7093                .commit_outcome("losing-commit")
7094                .expect("read outcome")
7095                .expect("outcome")
7096                .resolution,
7097            CommitOutcomeResolution::ResolvedKeepServer
7098        );
7099        drop(reopened);
7100        std::fs::remove_file(path).expect("remove temp database");
7101    }
7102
7103    #[test]
7104    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
7105        let path =
7106            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7107        let schema = json!({
7108            "version": 1,
7109            "tables": [{
7110                "name": "tasks",
7111                "primaryKey": "id",
7112                "columns": [
7113                    { "name": "id", "type": "string", "nullable": false },
7114                    { "name": "project_id", "type": "string", "nullable": false }
7115                ],
7116                "scopes": [{ "pattern": "project:{project_id}" }]
7117            }]
7118        });
7119        let mut client = SyncClient::open_path_with_identity(
7120            Some("sidecar-client".to_owned()),
7121            &schema,
7122            ClientLimits::default(),
7123            path.to_str().expect("UTF-8 temp path"),
7124        )
7125        .expect("open owner");
7126        let base = WindowBase {
7127            table: "tasks".to_owned(),
7128            variable: "project_id".to_owned(),
7129            fixed_scopes: Vec::new(),
7130            params: None,
7131        };
7132        client
7133            .set_window(&base, &["one".to_owned()])
7134            .expect("set window");
7135        client
7136            .mutate(vec![Mutation::Upsert {
7137                table: "tasks".to_owned(),
7138                values: Map::from_iter([
7139                    ("id".to_owned(), Value::from("t1")),
7140                    ("project_id".to_owned(), Value::from("one")),
7141                ]),
7142                base_version: None,
7143            }])
7144            .expect("local mutate");
7145
7146        let coverage = [WindowCoverage {
7147            base,
7148            units: vec!["one".to_owned(), "missing".to_owned()],
7149        }];
7150        let owner = client
7151            .query_snapshot(
7152                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7153                &[],
7154                &coverage,
7155                None,
7156            )
7157            .expect("owner snapshot");
7158        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7159        let sidecar = reader
7160            .query_snapshot(
7161                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7162                &[],
7163                &coverage,
7164            )
7165            .expect("sidecar snapshot");
7166
7167        assert_eq!(sidecar.revision, owner.revision);
7168        assert_eq!(sidecar.rows, owner.rows);
7169        assert_eq!(
7170            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
7171            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
7172        );
7173        assert_eq!(sidecar.revision, "2");
7174        assert_eq!(sidecar.rows[0]["id"], "t1");
7175        assert_eq!(sidecar.rows[0]["server_version"], -1);
7176        assert!(!sidecar.coverage.complete);
7177        assert_eq!(sidecar.coverage.pending.len(), 1);
7178        assert_eq!(sidecar.coverage.missing.len(), 1);
7179
7180        drop(reader);
7181        drop(client);
7182        std::fs::remove_file(path).expect("remove temp database");
7183    }
7184
7185    #[test]
7186    fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
7187        let path =
7188            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7189        let schema = json!({
7190            "version": 1,
7191            "tables": [{
7192                "name": "tasks",
7193                "primaryKey": "id",
7194                "columns": [
7195                    { "name": "id", "type": "string", "nullable": false },
7196                    { "name": "project_id", "type": "string", "nullable": false }
7197                ],
7198                "scopes": [{ "pattern": "project:{project_id}" }]
7199            }]
7200        });
7201        let mut client = SyncClient::open_path_with_identity(
7202            Some("sidecar-catchup".to_owned()),
7203            &schema,
7204            ClientLimits::default(),
7205            path.to_str().expect("UTF-8 temp path"),
7206        )
7207        .expect("open owner");
7208        client
7209            .subscribe(
7210                "s1".into(),
7211                "tasks".into(),
7212                vec![("project_id".into(), vec!["one".into()])],
7213                None,
7214            )
7215            .expect("subscribe");
7216        let commit_id = client
7217            .mutate(vec![Mutation::Upsert {
7218                table: "tasks".to_owned(),
7219                values: Map::from_iter([
7220                    ("id".to_owned(), Value::from("t1")),
7221                    ("project_id".to_owned(), Value::from("one")),
7222                ]),
7223                base_version: None,
7224            }])
7225            .expect("mutate");
7226        let revision = client.local_revision().to_string();
7227
7228        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7229        assert_eq!(reader.local_revision().expect("revision"), revision);
7230
7231        let read = reader
7232            .snapshot_read(&SnapshotReadRequest {
7233                statements: vec![
7234                    SnapshotStatement {
7235                        sql: "SELECT 1 AS n",
7236                        params: &[],
7237                    },
7238                    SnapshotStatement {
7239                        sql: "SELECT id FROM tasks ORDER BY id",
7240                        params: &[],
7241                    },
7242                ],
7243                coverage: &[],
7244                subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
7245                commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
7246            })
7247            .expect("snapshot read");
7248        assert_eq!(read.revision, revision);
7249        assert_eq!(read.queries.len(), 2);
7250        assert_eq!(read.queries[0][0]["n"], 1);
7251        assert_eq!(read.queries[1][0]["id"], "t1");
7252        match &read.subscriptions[0] {
7253            SubscriptionCatchup::Known {
7254                status,
7255                cursor,
7256                bootstrap_complete,
7257                known_pending_pages,
7258                ..
7259            } => {
7260                assert_eq!(status, "active");
7261                assert_eq!(*cursor, -1);
7262                assert!(!bootstrap_complete);
7263                assert!(known_pending_pages);
7264            }
7265            other => panic!("expected a known subscription: {other:?}"),
7266        }
7267        assert!(matches!(
7268            &read.subscriptions[1],
7269            SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
7270        ));
7271        assert!(matches!(
7272            &read.deliveries[0],
7273            CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
7274        ));
7275        assert!(matches!(
7276            &read.deliveries[1],
7277            CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
7278        ));
7279
7280        // A bad statement rolls the whole read back and releases the connection.
7281        assert!(reader
7282            .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
7283            .is_err());
7284        assert_eq!(
7285            reader.local_revision().expect("revision after rollback"),
7286            revision
7287        );
7288        // The read-only guard refuses a write on any statement.
7289        assert!(reader
7290            .query_snapshot("DELETE FROM tasks", &[], &[])
7291            .is_err());
7292
7293        // A corrupt revision marker fails typed, not as an invented zero.
7294        client
7295            .conn
7296            .execute(
7297                "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
7298                [],
7299            )
7300            .expect("corrupt revision");
7301        let failure = reader.local_revision().expect_err("corrupt revision fails");
7302        assert_eq!(failure.code, Some("sync.local_corrupt"));
7303
7304        drop(reader);
7305        drop(client);
7306        std::fs::remove_file(path).expect("remove temp database");
7307    }
7308
7309    #[test]
7310    fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
7311        let path =
7312            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7313        let schema = json!({
7314            "version": 1,
7315            "tables": [{
7316                "name": "tasks",
7317                "primaryKey": "id",
7318                "columns": [
7319                    { "name": "id", "type": "string", "nullable": false },
7320                    { "name": "project_id", "type": "string", "nullable": false }
7321                ],
7322                "scopes": [{ "pattern": "project:{project_id}" }]
7323            }]
7324        });
7325        let mut client = SyncClient::open_path_with_identity(
7326            Some("sidecar-delivery".to_owned()),
7327            &schema,
7328            ClientLimits::default(),
7329            path.to_str().expect("UTF-8 temp path"),
7330        )
7331        .expect("open owner");
7332        client.begin_observation("test_delivery").expect("observe");
7333        let operations = [OutboxOp {
7334            upsert: true,
7335            insert: false,
7336            table: "tasks".to_owned(),
7337            row_id: "t1".to_owned(),
7338            base_version: Some(1),
7339            values: Some(Map::from_iter([
7340                ("id".into(), json!("t1")),
7341                ("project_id".into(), json!("p1")),
7342            ])),
7343        }];
7344        client
7345            .persist_commit_outcome(
7346                "conflict-commit",
7347                CommitOutcomeStatus::Conflict,
7348                &[CommitOperationOutcome::Conflict {
7349                    conflict: ConflictRecord {
7350                        client_commit_id: "conflict-commit".to_owned(),
7351                        op_index: 0,
7352                        table: "tasks".to_owned(),
7353                        row_id: "t1".to_owned(),
7354                        code: "sync.version_conflict".to_owned(),
7355                        message: "stale base version".to_owned(),
7356                        server_version: 2,
7357                        server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
7358                        conflict_columns: vec!["project_id".to_owned()],
7359                        operation: None,
7360                    },
7361                }],
7362                Some(&operations),
7363            )
7364            .expect("persist conflict");
7365        client
7366            .persist_commit_outcome(
7367                "rejected-commit",
7368                CommitOutcomeStatus::Rejected,
7369                &[CommitOperationOutcome::Error {
7370                    rejection: RejectionRecord {
7371                        client_commit_id: "rejected-commit".to_owned(),
7372                        op_index: 0,
7373                        code: "sync.unknown_table".to_owned(),
7374                        message: "unknown table".to_owned(),
7375                        retryable: false,
7376                        details: None,
7377                        operation: None,
7378                    },
7379                }],
7380                Some(&operations),
7381            )
7382            .expect("persist rejection");
7383        client
7384            .finish_observation(
7385                "test_delivery",
7386                ChangeAccumulator {
7387                    conflicts: true,
7388                    outcomes: true,
7389                    ..ChangeAccumulator::default()
7390                },
7391            )
7392            .expect("commit outcomes");
7393
7394        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7395        match reader.commit_delivery("conflict-commit").expect("conflict") {
7396            CommitDelivery::Known { outcome, .. } => {
7397                assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7398                assert!(matches!(
7399                    outcome.results.as_slice(),
7400                    [CommitOperationOutcome::Conflict { conflict }]
7401                        if conflict.code == "sync.version_conflict"
7402                            && conflict.server_version == 2
7403                ));
7404                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7405                assert!(
7406                    outcome.retained_rows.is_none(),
7407                    "the sidecar reports the persisted view, not owner-derived retained rows"
7408                );
7409            }
7410            other => panic!("expected a known conflict: {other:?}"),
7411        }
7412        match reader
7413            .commit_delivery("rejected-commit")
7414            .expect("rejection")
7415        {
7416            CommitDelivery::Known { outcome, .. } => {
7417                assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
7418                assert!(matches!(
7419                    outcome.results.as_slice(),
7420                    [CommitOperationOutcome::Error { rejection }]
7421                        if rejection.code == "sync.unknown_table"
7422                ));
7423                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7424                assert!(outcome.retained_rows.is_none());
7425            }
7426            other => panic!("expected a known rejection: {other:?}"),
7427        }
7428        drop(reader);
7429        drop(client);
7430        std::fs::remove_file(path).expect("remove temp database");
7431    }
7432
7433    #[test]
7434    fn snapshot_read_isolates_a_concurrent_writer_commit() {
7435        use std::sync::mpsc::channel;
7436        let path = std::env::temp_dir().join(format!(
7437            "syncular-read-concurrent-{}.db",
7438            uuid::Uuid::new_v4()
7439        ));
7440        let schema = json!({
7441            "version": 1,
7442            "tables": [{
7443                "name": "tasks",
7444                "primaryKey": "id",
7445                "columns": [
7446                    { "name": "id", "type": "string", "nullable": false },
7447                    { "name": "project_id", "type": "string", "nullable": false }
7448                ],
7449                "scopes": [{ "pattern": "project:{project_id}" }]
7450            }]
7451        });
7452        let mut client = SyncClient::open_path_with_identity(
7453            Some("sidecar-concurrent".to_owned()),
7454            &schema,
7455            ClientLimits::default(),
7456            path.to_str().expect("UTF-8 temp path"),
7457        )
7458        .expect("open owner");
7459        client
7460            .mutate(vec![Mutation::Upsert {
7461                table: "tasks".to_owned(),
7462                values: Map::from_iter([
7463                    ("id".to_owned(), Value::from("t1")),
7464                    ("project_id".to_owned(), Value::from("one")),
7465                ]),
7466                base_version: None,
7467            }])
7468            .expect("seed row");
7469        let revision_before = client.local_revision().to_string();
7470
7471        // A writer on a second connection commits while the reader is between
7472        // statements; the hook rendezvous is a barrier, not a sleep.
7473        let (ready_tx, ready_rx) = channel::<()>();
7474        let (done_tx, done_rx) = channel::<()>();
7475        let writer_path = path.to_string_lossy().into_owned();
7476        let writer_schema = schema.clone();
7477        let writer = std::thread::spawn(move || {
7478            let mut writer = SyncClient::open_path_with_identity(
7479                Some("sidecar-concurrent".to_owned()),
7480                &writer_schema,
7481                ClientLimits::default(),
7482                &writer_path,
7483            )
7484            .expect("open writer");
7485            ready_rx.recv().expect("reader signalled");
7486            writer
7487                .mutate(vec![Mutation::Upsert {
7488                    table: "tasks".to_owned(),
7489                    values: Map::from_iter([
7490                        ("id".to_owned(), Value::from("t2")),
7491                        ("project_id".to_owned(), Value::from("one")),
7492                    ]),
7493                    base_version: None,
7494                }])
7495                .expect("writer commit");
7496            let _ = done_tx.send(());
7497        });
7498        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7499        SNAPSHOT_READ_HOOK.with(|slot| {
7500            *slot.borrow_mut() = Some(Box::new(move || {
7501                ready_tx.send(()).expect("signal writer");
7502                done_rx.recv().expect("writer finished");
7503            }));
7504        });
7505        let read = reader
7506            .snapshot_read(&SnapshotReadRequest {
7507                statements: vec![
7508                    SnapshotStatement {
7509                        sql: "SELECT count(*) AS n FROM tasks",
7510                        params: &[],
7511                    },
7512                    SnapshotStatement {
7513                        sql: "SELECT count(*) AS n FROM tasks",
7514                        params: &[],
7515                    },
7516                ],
7517                coverage: &[],
7518                subscriptions: Vec::new(),
7519                commit_ids: Vec::new(),
7520            })
7521            .expect("snapshot read");
7522        assert_eq!(
7523            read.queries[0][0]["n"], 1,
7524            "first statement sees the pre-commit row"
7525        );
7526        assert_eq!(
7527            read.queries[1][0]["n"], 1,
7528            "a later statement keeps the one read snapshot"
7529        );
7530        assert_eq!(
7531            read.revision, revision_before,
7532            "the read reports the snapshot revision, not the writer's"
7533        );
7534        writer.join().expect("join writer");
7535
7536        assert_ne!(
7537            reader.local_revision().expect("revision after commit"),
7538            revision_before,
7539            "the next read observes the committed revision"
7540        );
7541        let after = reader
7542            .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
7543            .expect("count after commit");
7544        assert_eq!(
7545            after.rows[0]["n"], 2,
7546            "the committed row is visible afterwards"
7547        );
7548
7549        drop(reader);
7550        drop(client);
7551        std::fs::remove_file(path).expect("remove temp database");
7552    }
7553
7554    #[test]
7555    fn snapshot_read_rejects_every_non_canonical_revision() {
7556        let path = std::env::temp_dir().join(format!(
7557            "syncular-read-revision-{}.db",
7558            uuid::Uuid::new_v4()
7559        ));
7560        let schema = json!({
7561            "version": 1,
7562            "tables": [{
7563                "name": "tasks",
7564                "primaryKey": "id",
7565                "columns": [
7566                    { "name": "id", "type": "string", "nullable": false },
7567                    { "name": "project_id", "type": "string", "nullable": false }
7568                ],
7569                "scopes": [{ "pattern": "project:{project_id}" }]
7570            }]
7571        });
7572        let client = SyncClient::open_path_with_identity(
7573            Some("sidecar-revision".to_owned()),
7574            &schema,
7575            ClientLimits::default(),
7576            path.to_str().expect("UTF-8 temp path"),
7577        )
7578        .expect("open owner");
7579        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7580        for bad in [
7581            "+1",
7582            "01",
7583            "-1",
7584            " 1",
7585            "1 ",
7586            "18446744073709551616",
7587            "1.0",
7588            "abc",
7589            "",
7590        ] {
7591            client
7592                .conn
7593                .execute(
7594                    "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
7595                    rusqlite::params![bad],
7596                )
7597                .expect("corrupt revision");
7598            let failure = reader.local_revision().expect_err("corrupt marker fails");
7599            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
7600        }
7601        for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
7602            client
7603                .conn
7604                .execute_batch(&format!(
7605                    "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
7606                ))
7607                .expect("corrupt stored revision type");
7608            assert_eq!(
7609                reader.local_revision().expect_err("invalid SQL field").code,
7610                Some("sync.local_corrupt")
7611            );
7612        }
7613        // The absent marker is the legacy zero, not corruption.
7614        client
7615            .conn
7616            .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
7617            .expect("delete marker");
7618        assert_eq!(reader.local_revision().expect("legacy zero"), "0");
7619
7620        drop(reader);
7621        drop(client);
7622        std::fs::remove_file(path).expect("remove temp database");
7623    }
7624
7625    #[test]
7626    fn snapshot_read_rejects_corrupt_subscription_state() {
7627        let path = std::env::temp_dir().join(format!(
7628            "syncular-read-substate-{}.db",
7629            uuid::Uuid::new_v4()
7630        ));
7631        let schema = json!({
7632            "version": 1,
7633            "tables": [{
7634                "name": "tasks",
7635                "primaryKey": "id",
7636                "columns": [
7637                    { "name": "id", "type": "string", "nullable": false },
7638                    { "name": "project_id", "type": "string", "nullable": false }
7639                ],
7640                "scopes": [{ "pattern": "project:{project_id}" }]
7641            }]
7642        });
7643        let mut client = SyncClient::open_path_with_identity(
7644            Some("sidecar-substate".to_owned()),
7645            &schema,
7646            ClientLimits::default(),
7647            path.to_str().expect("UTF-8 temp path"),
7648        )
7649        .expect("open owner");
7650        client
7651            .subscribe(
7652                "s1".into(),
7653                "tasks".into(),
7654                vec![("project_id".into(), vec!["one".into()])],
7655                None,
7656            )
7657            .expect("subscribe");
7658        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7659        for bad in [
7660            "not json".to_owned(),
7661            "{}".to_owned(),
7662            json!({ "status": "bogus", "cursor": 0 }).to_string(),
7663            json!({ "status": "active", "cursor": "x" }).to_string(),
7664            json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
7665            json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
7666        ] {
7667            client
7668                .conn
7669                .execute(
7670                    "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
7671                    rusqlite::params![bad],
7672                )
7673                .expect("corrupt subscription state");
7674            let failure = reader
7675                .subscription_catchup("s1")
7676                .expect_err("corrupt state fails");
7677            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
7678        }
7679
7680        for assignment in [
7681            "tbl=X'7461736b73'",
7682            "state_json=X'7b7d'",
7683            "state_json=CAST(X'FF' AS TEXT)",
7684        ] {
7685            client.conn.execute_batch(&format!(
7686                "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
7687            )).expect("corrupt stored subscription type");
7688            assert_eq!(
7689                reader
7690                    .subscription_catchup("s1")
7691                    .expect_err("invalid SQL field")
7692                    .code,
7693                Some("sync.local_corrupt")
7694            );
7695        }
7696
7697        drop(reader);
7698        drop(client);
7699        std::fs::remove_file(path).expect("remove temp database");
7700    }
7701
7702    #[test]
7703    fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
7704        let path = std::env::temp_dir().join(format!(
7705            "syncular-read-rollback-{}.db",
7706            uuid::Uuid::new_v4()
7707        ));
7708        let schema = json!({
7709            "version": 1,
7710            "tables": [{
7711                "name": "tasks",
7712                "primaryKey": "id",
7713                "columns": [
7714                    { "name": "id", "type": "string", "nullable": false },
7715                    { "name": "project_id", "type": "string", "nullable": false }
7716                ],
7717                "scopes": [{ "pattern": "project:{project_id}" }]
7718            }]
7719        });
7720        let mut client = SyncClient::open_path_with_identity(
7721            Some("sidecar-rollback".to_owned()),
7722            &schema,
7723            ClientLimits::default(),
7724            path.to_str().expect("UTF-8 temp path"),
7725        )
7726        .expect("open owner");
7727        let commit_id = client
7728            .mutate(vec![Mutation::Upsert {
7729                table: "tasks".to_owned(),
7730                values: Map::from_iter([
7731                    ("id".to_owned(), Value::from("t1")),
7732                    ("project_id".to_owned(), Value::from("one")),
7733                ]),
7734                base_version: None,
7735            }])
7736            .expect("seed row");
7737        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7738        let revision = reader.local_revision().expect("revision");
7739
7740        // The second statement fails: the whole read rolls back and the
7741        // connection releases, so the same reader still works afterwards.
7742        let failure = reader
7743            .snapshot_read(&SnapshotReadRequest {
7744                statements: vec![
7745                    SnapshotStatement {
7746                        sql: "SELECT id FROM tasks",
7747                        params: &[],
7748                    },
7749                    SnapshotStatement {
7750                        sql: "SELECT * FROM does_not_exist",
7751                        params: &[],
7752                    },
7753                ],
7754                coverage: &[],
7755                subscriptions: Vec::new(),
7756                commit_ids: vec![commit_id.clone()],
7757            })
7758            .expect_err("second statement fails");
7759        assert_eq!(failure.code, None);
7760        assert_eq!(
7761            reader.local_revision().expect("revision after rollback"),
7762            revision,
7763            "a rolled-back read consumes no revision"
7764        );
7765        assert!(
7766            !matches!(
7767                reader
7768                    .commit_delivery(&commit_id)
7769                    .expect("delivery after rollback"),
7770                CommitDelivery::Unknown { .. }
7771            ),
7772            "the same reader serves the next read after releasing the connection"
7773        );
7774
7775        // The read-only guard rejects a write in the first position too.
7776        let guard = reader
7777            .snapshot_read(&SnapshotReadRequest {
7778                statements: vec![SnapshotStatement {
7779                    sql: "DELETE FROM tasks",
7780                    params: &[],
7781                }],
7782                coverage: &[],
7783                subscriptions: Vec::new(),
7784                commit_ids: Vec::new(),
7785            })
7786            .expect_err("write guard");
7787        assert!(guard.message.contains("read-only"), "{}", guard.message);
7788
7789        drop(reader);
7790        drop(client);
7791        std::fs::remove_file(path).expect("remove temp database");
7792    }
7793
7794    #[test]
7795    fn owned_query_failures_classify_order_bound_and_clear() {
7796        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7797            rusqlite::ffi::Error::new(266),
7798            Some("private SQLite prose".to_owned()),
7799        ));
7800        assert_eq!(io.code, Some("client.storage_io"));
7801        assert_eq!(io.sqlite_code, Some(266));
7802        assert_eq!(io.message, "local SQLite storage I/O failed");
7803        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7804            rusqlite::ffi::Error::new(26),
7805            None,
7806        ));
7807        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7808        assert!(!io.retryable());
7809        assert!(!corrupt.retryable());
7810
7811        // SQLITE_BUSY (5) and SQLITE_LOCKED (6), including extended codes, are
7812        // transient lock contention: classified busy and retryable.
7813        for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7814            let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7815                rusqlite::ffi::Error::new(raw),
7816                Some("database is locked".to_owned()),
7817            ));
7818            assert_eq!(busy.code, Some("client.storage_busy"));
7819            assert_eq!(busy.sqlite_code, Some(extended));
7820            assert_eq!(busy.message, "local SQLite storage is busy");
7821            assert!(busy.retryable());
7822        }
7823        let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7824            rusqlite::ffi::Error::new(1),
7825            Some("generic SQLite prose".to_owned()),
7826        ));
7827        assert_eq!(unrelated.code, None);
7828        assert!(!unrelated.retryable());
7829
7830        let mut client = client();
7831        let generic = QueryReadFailure::from("private query prose".to_owned());
7832        client.record_query_read(
7833            QueryOwner {
7834                id: "first",
7835                tables: &["tasks", "tasks"],
7836            },
7837            Some(&generic),
7838        );
7839        client.record_query_read(
7840            QueryOwner {
7841                id: "second",
7842                tables: &["tasks"],
7843            },
7844            Some(&generic),
7845        );
7846        client.record_query_read(
7847            QueryOwner {
7848                id: "first",
7849                tables: &["docs"],
7850            },
7851            Some(&io),
7852        );
7853        let failures = &client
7854            .diagnostics_snapshot(&Default::default())
7855            .expect("diagnostics")
7856            .query_failures;
7857        assert_eq!(
7858            failures
7859                .iter()
7860                .map(|entry| entry.id.as_str())
7861                .collect::<Vec<_>>(),
7862            vec!["second", "first"]
7863        );
7864        assert_eq!(failures[1].tables, vec!["docs"]);
7865        assert_eq!(failures[1].code, "client.storage_io");
7866
7867        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7868            client.record_query_read(
7869                QueryOwner {
7870                    id: &format!("query-{index}"),
7871                    tables: &["tasks"],
7872                },
7873                Some(&generic),
7874            );
7875        }
7876        let failures = &client
7877            .diagnostics_snapshot(&Default::default())
7878            .expect("bounded diagnostics")
7879            .query_failures;
7880        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7881        assert_eq!(failures.last().expect("last failure").id, "query-256");
7882
7883        client
7884            .query_snapshot(
7885                "SELECT id FROM tasks",
7886                &[],
7887                &[],
7888                Some(QueryOwner {
7889                    id: "query-256",
7890                    tables: &["tasks"],
7891                }),
7892            )
7893            .expect("successful owned read");
7894        assert!(client
7895            .diagnostics_snapshot(&Default::default())
7896            .expect("cleared diagnostics")
7897            .query_failures
7898            .iter()
7899            .all(|entry| entry.id != "query-256"));
7900    }
7901
7902    #[test]
7903    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7904        let mut client = client();
7905        let image = Connection::open_in_memory().unwrap();
7906        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7907            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7908            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7909            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7910            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7911        let table = client.schema.table("tasks").unwrap().clone();
7912        let scopes = vec![("project_id".into(), vec!["p1".into()])];
7913        assert!(client
7914            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7915            .is_err());
7916        assert!(client.conn.is_autocommit());
7917        assert_eq!(
7918            client
7919                .query("SELECT count(*) AS n FROM tasks", &[])
7920                .unwrap()[0]["n"],
7921            1024
7922        );
7923        assert_eq!(client.drain_change_batches().len(), 1);
7924        image
7925            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7926            .unwrap();
7927        assert!(matches!(
7928            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7929            Ok(2050)
7930        ));
7931        assert_eq!(
7932            client
7933                .query("SELECT count(*) AS n FROM tasks", &[])
7934                .unwrap()[0]["n"],
7935            2050
7936        );
7937        assert_eq!(client.drain_change_batches().len(), 3);
7938    }
7939
7940    #[test]
7941    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7942        let mut client = client();
7943        let base = WindowBase {
7944            table: "tasks".into(),
7945            variable: "project_id".into(),
7946            fixed_scopes: Vec::new(),
7947            params: None,
7948        };
7949        client
7950            .set_window(&base, &["p1".into(), "p2".into()])
7951            .unwrap();
7952        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7953            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7954            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7955            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7956            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7957        client.drain_change_batches();
7958        assert!(client.set_window(&base, &["p2".into()]).is_err());
7959        assert!(client.conn.is_autocommit());
7960        assert_eq!(
7961            client
7962                .query("SELECT count(*) AS n FROM tasks", &[])
7963                .unwrap()[0]["n"],
7964            1027
7965        );
7966        assert_eq!(client.window_state(&base).units, vec!["p2"]);
7967        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7968        assert_eq!(client.drain_change_batches().len(), 1);
7969        client
7970            .conn
7971            .execute_batch("DROP TRIGGER interrupt_evict")
7972            .unwrap();
7973        client.drain_pending_evictions().unwrap();
7974        assert!(client.load_pending_evictions().unwrap().is_empty());
7975        assert_eq!(
7976            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7977            "held"
7978        );
7979        assert_eq!(client.drain_change_batches().len(), 2);
7980    }
7981
7982    #[test]
7983    fn eviction_marker_failures_roll_back_window_changes() {
7984        let mut client = client();
7985        let base = WindowBase {
7986            table: "tasks".into(),
7987            variable: "project_id".into(),
7988            fixed_scopes: Vec::new(),
7989            params: None,
7990        };
7991        client.set_window(&base, &["p1".into()]).unwrap();
7992        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7993            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7994            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7995            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7996        let revision = client.local_revision();
7997        assert!(client.set_window(&base, &[]).is_err());
7998        assert!(client.conn.is_autocommit());
7999        assert_eq!(client.local_revision(), revision);
8000        assert_eq!(
8001            client
8002                .query("SELECT count(*) AS n FROM tasks", &[])
8003                .unwrap()[0]["n"],
8004            2050
8005        );
8006        assert_eq!(client.window_state(&base).units, vec!["p1"]);
8007        client.conn.execute_batch("DROP TRIGGER fail_marker;
8008            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
8009        assert!(client.set_window(&base, &[]).is_err());
8010        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
8011            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
8012        assert!(client.drain_pending_evictions().is_err());
8013        assert!(client.conn.is_autocommit());
8014        assert_eq!(
8015            client
8016                .query("SELECT count(*) AS n FROM tasks", &[])
8017                .unwrap()[0]["n"],
8018            2
8019        );
8020        assert!(client.set_window(&base, &["p1".into()]).is_err());
8021        assert!(client.conn.is_autocommit());
8022        assert!(client.window_state(&base).units.is_empty());
8023        assert!(client.subs.is_empty());
8024        assert_eq!(
8025            client
8026                .query(
8027                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
8028                    &[]
8029                )
8030                .unwrap()[0]["n"],
8031            1
8032        );
8033        client
8034            .conn
8035            .execute_batch("DROP TRIGGER fail_marker")
8036            .unwrap();
8037        client.set_window(&base, &["p1".into()]).unwrap();
8038        assert_eq!(client.window_state(&base).units, vec!["p1"]);
8039    }
8040
8041    #[test]
8042    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
8043        let mut client = client();
8044        client
8045            .conn
8046            .execute(
8047                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
8048                [],
8049            )
8050            .unwrap();
8051        assert_eq!(
8052            client.drain_pending_evictions().unwrap_err(),
8053            "sync.local_corrupt: invalid pending eviction scopes"
8054        );
8055        assert!(client.conn.is_autocommit());
8056        assert!(client.enqueue_startup_sync_if_needed().is_err());
8057    }
8058
8059    #[test]
8060    fn image_chunks_reject_null_primary_keys() {
8061        let mut client = client();
8062        let image = Connection::open_in_memory().unwrap();
8063        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8064            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
8065            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
8066            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8067            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
8068        let table = client.schema.table("tasks").unwrap().clone();
8069        assert!(client
8070            .apply_sqlite_image(
8071                &image,
8072                &table,
8073                true,
8074                &[("project_id".into(), vec!["p1".into()])],
8075                1025,
8076                7,
8077                "digest"
8078            )
8079            .is_err());
8080        assert!(client.conn.is_autocommit());
8081        assert_eq!(
8082            client
8083                .query("SELECT count(*) AS n FROM tasks", &[])
8084                .unwrap()[0]["n"],
8085            0
8086        );
8087    }
8088
8089    #[test]
8090    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
8091        for images in [false, true] {
8092            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8093                "name":name,"primaryKey":"id","columns":[
8094                    {"name":"id","type":"string","nullable":false},
8095                    {"name":"project_id","type":"string","nullable":false},
8096                    {"name":"title","type":"string","nullable":false}],
8097                "scopes":[{"pattern":"project:{project_id}"}],
8098                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
8099                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8100            })))});
8101            let mut actual =
8102                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
8103            let mut reference =
8104                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
8105            let scopes = vec![("project_id".into(), vec!["p1".into()])];
8106            for client in [&mut actual, &mut reference] {
8107                client
8108                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
8109                    .unwrap();
8110                client.subs[0].effective = Some(scopes.clone());
8111                for (table, id, title) in [
8112                    ("tasks", "a", "original"),
8113                    ("tasks", "b", "occupied"),
8114                    ("tasks", "c", "deleted"),
8115                    ("notes", "n", "untouched"),
8116                ] {
8117                    client
8118                        .write_base_row(
8119                            table,
8120                            &vec![
8121                                Some(ColumnValue::String(id.into())),
8122                                Some(ColumnValue::String("p1".into())),
8123                                Some(ColumnValue::String(title.into())),
8124                            ],
8125                            1,
8126                        )
8127                        .unwrap();
8128                }
8129                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8130                client
8131                    .mutate(vec![
8132                        Mutation::Upsert {
8133                            table: "tasks".into(),
8134                            values: Map::from_iter([
8135                                ("id".into(), Value::from("a")),
8136                                ("project_id".into(), Value::from("p1")),
8137                                ("title".into(), Value::from("available")),
8138                            ]),
8139                            base_version: None,
8140                        },
8141                        Mutation::Delete {
8142                            table: "tasks".into(),
8143                            row_id: "c".into(),
8144                            base_version: None,
8145                        },
8146                        Mutation::Upsert {
8147                            table: "tasks".into(),
8148                            values: Map::from_iter([
8149                                ("id".into(), Value::from("local")),
8150                                ("project_id".into(), Value::from("p1")),
8151                                ("title".into(), Value::from("local value")),
8152                            ]),
8153                            base_version: None,
8154                        },
8155                        Mutation::Upsert {
8156                            table: "notes".into(),
8157                            values: Map::from_iter([
8158                                ("id".into(), Value::from("n")),
8159                                ("project_id".into(), Value::from("p1")),
8160                                ("title".into(), Value::from("pending note")),
8161                            ]),
8162                            base_version: None,
8163                        },
8164                    ])
8165                    .unwrap();
8166            }
8167            let pending = actual.pending_commit_ids();
8168            actual.overlay_rebuild_count.set(0);
8169            for (id, title) in [
8170                ("b", "available"),
8171                ("b", "occupied"),
8172                ("a", "server original"),
8173                ("d", "local value"),
8174                ("d", "released"),
8175                ("c", "redelivered"),
8176            ] {
8177                let table = actual.schema.table("tasks").unwrap().clone();
8178                let values = vec![
8179                    Some(ColumnValue::String(id.into())),
8180                    Some(ColumnValue::String("p1".into())),
8181                    Some(ColumnValue::String(title.into())),
8182                ];
8183                if images {
8184                    let image = Connection::open_in_memory().unwrap();
8185                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8186                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8187                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8188                    image
8189                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
8190                        .unwrap();
8191                    assert!(matches!(
8192                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
8193                        Ok(1)
8194                    ));
8195                } else {
8196                    let segment = RowsSegment {
8197                        table: "tasks".into(),
8198                        schema_version: 1,
8199                        columns: table.wire_columns.clone(),
8200                        blocks: vec![vec![ssp2::segment::SegmentRow {
8201                            server_version: 2,
8202                            values: values.clone(),
8203                        }]],
8204                    };
8205                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
8206                }
8207                reference.write_base_row("tasks", &values, 2).unwrap();
8208                reference.rebuild_overlay().expect("rebuild overlay");
8209                for table in ["tasks", "notes"] {
8210                    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")] {
8211                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
8212                    }
8213                }
8214                assert_eq!(actual.pending_commit_ids(), pending);
8215            }
8216            assert_eq!(
8217                actual.overlay_rebuild_count.get(),
8218                0,
8219                "each chunk must leave unrelated tables alone"
8220            );
8221            let before = actual
8222                .query("SELECT * FROM tasks ORDER BY id", &[])
8223                .unwrap();
8224            let fts_before = actual
8225                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8226                .unwrap();
8227            let revision = actual.local_revision();
8228            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
8229            let table = actual.schema.table("tasks").unwrap().clone();
8230            let segment = RowsSegment {
8231                table: "tasks".into(),
8232                schema_version: 1,
8233                columns: table.wire_columns.clone(),
8234                blocks: vec![vec![ssp2::segment::SegmentRow {
8235                    server_version: 3,
8236                    values: vec![
8237                        Some(ColumnValue::String("b".into())),
8238                        Some(ColumnValue::String("p1".into())),
8239                        Some(ColumnValue::String("fresh".into())),
8240                    ],
8241                }]],
8242            };
8243            assert!(actual.apply_segment(0, &segment, false).is_err());
8244            assert!(actual.conn.is_autocommit());
8245            assert_eq!(
8246                actual
8247                    .query("SELECT * FROM tasks ORDER BY id", &[])
8248                    .unwrap(),
8249                before
8250            );
8251            assert_eq!(
8252                actual
8253                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8254                    .unwrap(),
8255                fts_before
8256            );
8257            assert_eq!(actual.local_revision(), revision);
8258            assert_eq!(actual.pending_commit_ids(), pending);
8259        }
8260    }
8261
8262    #[test]
8263    fn import_reconciliation_preserves_typed_pending_primary_keys() {
8264        for (kind, a, b) in [
8265            ("string", json!("a"), json!("b")),
8266            ("integer", json!(1), json!(2)),
8267            ("boolean", json!(true), json!(false)),
8268            ("json", json!("1"), json!("2")),
8269        ] {
8270            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}]}]});
8271            for images in [false, true] {
8272                let mut client =
8273                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
8274                let table = client.schema.table("tasks").unwrap().clone();
8275                client
8276                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
8277                    .unwrap();
8278                for (id, title) in [(&a, "original"), (&b, "occupied")] {
8279                    client
8280                        .write_base_row(
8281                            "tasks",
8282                            &vec![
8283                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
8284                                Some(ColumnValue::String(title.into())),
8285                            ],
8286                            1,
8287                        )
8288                        .unwrap();
8289                }
8290                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8291                client
8292                    .mutate(vec![Mutation::Upsert {
8293                        table: "tasks".into(),
8294                        values: Map::from_iter([
8295                            ("id".into(), a.clone()),
8296                            ("title".into(), json!("available")),
8297                        ]),
8298                        base_version: None,
8299                    }])
8300                    .unwrap();
8301                client.overlay_rebuild_count.set(0);
8302                for (title, expected) in [("available", "original"), ("occupied", "available")] {
8303                    let values = vec![
8304                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
8305                        Some(ColumnValue::String(title.into())),
8306                    ];
8307                    if images {
8308                        let image = Connection::open_in_memory().unwrap();
8309                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8310                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8311                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8312                        image
8313                            .execute(
8314                                "INSERT INTO tasks VALUES (?1,?2,2)",
8315                                rusqlite::params![RowParam::Cell(&values[0]), title],
8316                            )
8317                            .unwrap();
8318                        assert!(
8319                            matches!(
8320                                client.apply_sqlite_image(
8321                                    &image,
8322                                    &table,
8323                                    false,
8324                                    &[],
8325                                    1,
8326                                    7,
8327                                    "digest"
8328                                ),
8329                                Ok(1)
8330                            ),
8331                            "{kind}, images={images}"
8332                        );
8333                    } else {
8334                        let segment = RowsSegment {
8335                            table: "tasks".into(),
8336                            schema_version: 1,
8337                            columns: table.wire_columns.clone(),
8338                            blocks: vec![vec![ssp2::segment::SegmentRow {
8339                                server_version: 2,
8340                                values,
8341                            }]],
8342                        };
8343                        assert!(
8344                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
8345                            "{kind}"
8346                        );
8347                    }
8348                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
8349                    let actual: String = client
8350                        .conn
8351                        .query_row(
8352                            "SELECT title FROM tasks WHERE id=?1",
8353                            [RowParam::Cell(&key)],
8354                            |row| row.get(0),
8355                        )
8356                        .unwrap();
8357                    assert_eq!(actual, expected, "{kind}, images={images}");
8358                }
8359                assert_eq!(client.overlay_rebuild_count.get(), 0);
8360            }
8361        }
8362    }
8363
8364    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
8365        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8366            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
8367            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
8368            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8369        })))});
8370        let mut client =
8371            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
8372        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();
8373        client.rebuild_overlay().expect("rebuild overlay");
8374        client
8375            .mutate(vec![Mutation::Upsert {
8376                table: "tasks".into(),
8377                values: Map::from_iter([
8378                    ("id".into(), json!("offline")),
8379                    ("project_id".into(), json!("p1")),
8380                    ("title".into(), json!("pending edit")),
8381                ]),
8382                base_version: None,
8383            }])
8384            .unwrap();
8385        let image = Connection::open_in_memory().unwrap();
8386        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8387            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
8388        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();
8389        image
8390            .execute(
8391                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
8392                [rows],
8393            )
8394            .unwrap();
8395        client.overlay_rebuild_count.set(0);
8396        let table = client.schema.table("tasks").unwrap().clone();
8397        (client, image, table)
8398    }
8399
8400    #[test]
8401    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
8402        let (mut client, image, table) = unique_import_fixture(1);
8403        image
8404            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
8405            .unwrap();
8406        client
8407            .conn
8408            .execute_batch("PRAGMA max_page_count = 1")
8409            .unwrap();
8410        let effective = [("project_id".into(), vec!["p1".into()])];
8411        assert!(client
8412            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
8413            .is_err());
8414        let failure = client.storage_failure.borrow().clone().unwrap();
8415        assert_eq!(failure.code, Some("client.storage_full"));
8416        assert_eq!(failure.sqlite_code, Some(13));
8417        assert!(!failure.message.contains("rollback"));
8418        assert!(client.conn.is_autocommit());
8419        client
8420            .conn
8421            .execute_batch("PRAGMA max_page_count = 1073741823")
8422            .unwrap();
8423        assert!(matches!(
8424            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
8425            Ok(1)
8426        ));
8427        assert!(client.conn.is_autocommit());
8428    }
8429
8430    #[test]
8431    fn authoring_storage_failures_are_structured_and_scoped() {
8432        let path = std::env::temp_dir().join(format!(
8433            "syncular-authoring-failure-{}.db",
8434            uuid::Uuid::new_v4()
8435        ));
8436        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8437            {"name":"id","type":"string","nullable":false},
8438            {"name":"project_id","type":"string","nullable":false},
8439            {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8440        let mut client = SyncClient::open_path(
8441            "authoring".into(),
8442            &schema,
8443            ClientLimits::default(),
8444            path.to_str().unwrap(),
8445        )
8446        .expect("open client");
8447        client.create_synced_tables().expect("create synced tables");
8448        // Return BUSY immediately rather than waiting out the file timeout.
8449        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8450
8451        let upsert = |id: &str| {
8452            vec![Mutation::Upsert {
8453                table: "tasks".into(),
8454                values: Map::from_iter([
8455                    ("id".into(), json!(id)),
8456                    ("project_id".into(), json!("p1")),
8457                    ("title".into(), json!("first")),
8458                ]),
8459                base_version: None,
8460            }]
8461        };
8462
8463        let locker = Connection::open(&path).expect("second connection");
8464        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8465        let busy = client.mutate(upsert("t1")).expect_err("busy write");
8466        assert_eq!(busy.code, "client.storage_busy");
8467        assert!(busy.retryable);
8468        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8469        assert!(client.pending_commit_ids().is_empty());
8470        assert_eq!(client.local_revision(), 0);
8471
8472        // A stale classified failure must not classify a later unrelated one.
8473        let invalid = client.mutate(Vec::new()).expect_err("empty commit");
8474        assert_eq!(invalid.code, "sync.invalid_request");
8475        assert_eq!(invalid.message, "the authoring request is invalid");
8476        assert!(
8477            invalid.details.as_ref().unwrap()["legacyCause"]
8478                .as_str()
8479                .unwrap()
8480                .contains("at least one operation"),
8481            "{invalid:?}"
8482        );
8483
8484        locker.execute_batch("ROLLBACK").unwrap();
8485        let id = client.mutate(upsert("t1")).expect("retry after busy");
8486        assert_eq!(client.pending_commit_ids(), vec![id]);
8487        assert_eq!(client.local_revision(), 1);
8488
8489        // A later storage failure reports its own code and details.
8490        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8491        let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
8492        assert_eq!(busy_again.code, "client.storage_busy");
8493        assert!(busy_again.retryable);
8494        locker.execute_batch("ROLLBACK").unwrap();
8495
8496        // Non-retryable classified failures keep their sqlite evidence too.
8497        let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
8498            rusqlite::ffi::Error::new(13),
8499            Some("database or disk is full".to_owned()),
8500        )));
8501        assert_eq!(full.code, "client.storage_full");
8502        assert!(!full.retryable);
8503        assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
8504
8505        drop(locker);
8506        drop(client);
8507        let _ = std::fs::remove_file(&path);
8508    }
8509
8510    #[test]
8511    fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
8512        let path = std::env::temp_dir().join(format!(
8513            "syncular-caller-values-{}.db",
8514            uuid::Uuid::new_v4()
8515        ));
8516        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8517            {"name":"id","type":"string","nullable":false},
8518            {"name":"project_id","type":"string","nullable":false},
8519            {"name":"title","type":"string","nullable":false},
8520            {"name":"priority","type":"integer","nullable":false},
8521            {"name":"payload","type":"bytes","nullable":false},
8522            {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8523        let mut client = SyncClient::open_path(
8524            "caller-values".into(),
8525            &schema,
8526            ClientLimits::default(),
8527            path.to_str().unwrap(),
8528        )
8529        .expect("open client");
8530        client.create_synced_tables().expect("create synced tables");
8531
8532        let values = |id: &str| {
8533            Map::from_iter([
8534                ("id".to_owned(), json!(id)),
8535                ("project_id".to_owned(), json!("p1")),
8536                ("title".to_owned(), json!("first")),
8537                ("priority".to_owned(), json!(1)),
8538                ("payload".to_owned(), json!({"$bytes": "0a"})),
8539            ])
8540        };
8541        let upsert = |values: Map<String, Value>| {
8542            vec![Mutation::Upsert {
8543                table: "tasks".into(),
8544                values,
8545                base_version: None,
8546            }]
8547        };
8548        let seed = client.mutate(upsert(values("t1"))).expect("seed row");
8549        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
8550
8551        // §6.1/§7.1: every caller value the authoring seam rejects reports the
8552        // same stable identity instead of the internal-failure default, keeps
8553        // the dynamic cause in `details.legacyCause`, and leaves the durable
8554        // outbox, the local revision and the projection untouched.
8555        for (label, override_value, cause) in [
8556            ("unknown column", ("nope", json!(1)), "unknown column"),
8557            (
8558                "internal sync column",
8559                ("_sync_version", json!(1)),
8560                "internal sync column",
8561            ),
8562            ("wrong value type", ("title", json!(7)), "expected a string"),
8563            (
8564                "malformed bytes envelope",
8565                ("payload", json!({"$bytes": "zz"})),
8566                "expected exactly",
8567            ),
8568            (
8569                "odd-length bytes envelope",
8570                ("payload", json!({"$bytes": "abc"})),
8571                "expected exactly",
8572            ),
8573            (
8574                "plus-sign bytes envelope",
8575                ("payload", json!({"$bytes": "+a"})),
8576                "expected exactly",
8577            ),
8578            (
8579                "bytes envelope with a second key",
8580                ("payload", json!({"$bytes": "0a", "extra": 1})),
8581                "expected exactly",
8582            ),
8583            (
8584                "no bytes envelope",
8585                ("payload", json!({"nested": true})),
8586                "expected exactly",
8587            ),
8588        ] {
8589            let mut full = values("t2");
8590            full.insert(override_value.0.to_owned(), override_value.1);
8591            let error = client.mutate(upsert(full)).expect_err(label);
8592            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8593            assert_eq!(error.message, "the authoring request is invalid", "{label}");
8594            assert!(!error.retryable, "{label}");
8595            let legacy = error.details.as_ref().unwrap()["legacyCause"]
8596                .as_str()
8597                .unwrap();
8598            assert!(
8599                legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
8600                "{label}: {legacy}"
8601            );
8602            assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
8603            assert_eq!(client.local_revision(), 1, "{label}");
8604            assert_eq!(
8605                client.query("SELECT * FROM tasks", &[]).unwrap(),
8606                rows,
8607                "{label}"
8608            );
8609        }
8610
8611        // A full-row upsert presents every column, so a missing required one is
8612        // the caller's failure too.
8613        let mut missing = values("t3");
8614        missing.remove("payload");
8615        let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
8616        assert_eq!(missing_error.code, "sync.invalid_request");
8617        assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
8618            .as_str()
8619            .unwrap()
8620            .contains("is not nullable"));
8621
8622        // A primary key the wire cannot render is rejected before any op is
8623        // recorded.
8624        let mut bad_row_id = values("t4");
8625        bad_row_id.insert("id".to_owned(), json!({"nested": true}));
8626        let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
8627        assert_eq!(row_id_error.code, "sync.invalid_request");
8628
8629        // The sparse route classifies the same value failures.
8630        for (label, partial, cause) in [
8631            (
8632                "patch wrong value type",
8633                ("priority", json!("high")),
8634                "expected an integer",
8635            ),
8636            ("patch unknown column", ("nope", json!(1)), "unknown column"),
8637        ] {
8638            let error = client
8639                .patch(
8640                    "tasks",
8641                    "t1",
8642                    Map::from_iter([(partial.0.to_owned(), partial.1)]),
8643                    None,
8644                )
8645                .expect_err(label);
8646            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8647            assert_eq!(error.message, "the authoring request is invalid", "{label}");
8648            assert!(
8649                error.details.as_ref().unwrap()["legacyCause"]
8650                    .as_str()
8651                    .unwrap()
8652                    .contains(cause),
8653                "{label}"
8654            );
8655        }
8656        assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
8657        assert_eq!(client.local_revision(), 1);
8658        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
8659
8660        // Nullable explicit null, an empty envelope, and uppercase digits stay
8661        // accepted; the stored payload is the decoded byte sequence.
8662        for (label, payload) in [
8663            ("explicit null", json!(null)),
8664            ("empty envelope", json!({"$bytes": ""})),
8665            ("uppercase envelope", json!({"$bytes": "0AFF"})),
8666        ] {
8667            let mut accepted = values("t-ok");
8668            accepted.insert("extra".to_owned(), payload);
8669            let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
8670                panic!("{label} must be accepted: {error:?}");
8671            });
8672            assert!(!encoded.is_empty(), "{label}");
8673        }
8674
8675        // Nothing is poisoned: the next valid authoring call succeeds.
8676        let next = client
8677            .mutate(upsert(values("t5")))
8678            .expect("valid after failures");
8679        assert_ne!(next, seed);
8680
8681        drop(client);
8682        let _ = std::fs::remove_file(&path);
8683    }
8684
8685    #[test]
8686    fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
8687        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8688            {"name":"id","type":"string","nullable":false},
8689            {"name":"project_id","type":"string","nullable":false},
8690            {"name":"payload","type":"bytes","nullable":true}],
8691            "scopes":[{"pattern":"project:{project_id}"}]}]});
8692        let open = |path: &std::path::Path| {
8693            SyncClient::open_path(
8694                "legacy-values".into(),
8695                &schema,
8696                ClientLimits::default(),
8697                path.to_str().unwrap(),
8698            )
8699            .expect("open client")
8700        };
8701        let row = |id: &str| {
8702            Map::from_iter([
8703                ("id".to_owned(), json!(id)),
8704                ("project_id".to_owned(), json!("p1")),
8705                ("payload".to_owned(), json!({"$bytes": "0a"})),
8706            ])
8707        };
8708        let upsert = |values| {
8709            vec![Mutation::Upsert {
8710                table: "tasks".into(),
8711                values,
8712                base_version: None,
8713            }]
8714        };
8715        for (label, stored_cell, representable) in [
8716            ("plus-sign envelope", json!({"$bytes": "+a"}), false),
8717            ("non-hex envelope", json!({"$bytes": "zz"}), false),
8718            ("odd-length envelope", json!({"$bytes": "abc"}), false),
8719            ("no envelope", json!({"nested": true}), false),
8720            // A wrong declared type and a stored null for a non-nullable column
8721            // are journalable as stored, so their rejection keeps the operation
8722            // envelope.
8723            ("wrong declared type", json!("high"), true),
8724            ("stored null for a non-nullable column", json!(null), true),
8725        ] {
8726            let path = std::env::temp_dir().join(format!(
8727                "syncular-legacy-values-{}-{}.db",
8728                label.replace(' ', "-"),
8729                uuid::Uuid::new_v4()
8730            ));
8731            let mut client = open(&path);
8732            client.create_synced_tables().expect("create synced tables");
8733            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8734            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8735            drop(client);
8736
8737            // The stored intent an earlier version accepted.
8738            let connection = Connection::open(&path).expect("raw connection");
8739            let (column, cell) = if stored_cell.is_null() {
8740                ("project_id", stored_cell.clone())
8741            } else {
8742                ("payload", stored_cell.clone())
8743            };
8744            let mut planted_values = json!({
8745                "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
8746            });
8747            planted_values[column] = cell;
8748            let planted = json!([{
8749                "op": "upsert",
8750                "table": "tasks",
8751                "rowId": "t1",
8752                "baseVersion": null,
8753                "values": planted_values
8754            }]);
8755            connection
8756                .execute(
8757                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8758                    rusqlite::params![planted.to_string(), stale],
8759                )
8760                .expect("plant legacy intent");
8761            drop(connection);
8762
8763            // Reopen: the startup reconciliation recovers the commit before any
8764            // replay, so the observable phase matches the browser core.
8765            let reopened = open(&path);
8766            assert_eq!(
8767                reopened.pending_commit_ids(),
8768                vec![successor.clone()],
8769                "{label}"
8770            );
8771            let rejection = reopened
8772                .rejections()
8773                .iter()
8774                .find(|rejection| rejection.client_commit_id == stale)
8775                .unwrap_or_else(|| panic!("{label}: rejection"));
8776            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8777            assert_eq!(
8778                rejection.message, "the persisted commit carries values the current codec refuses",
8779                "{label}"
8780            );
8781            assert_eq!(rejection.operation.is_some(), representable, "{label}");
8782            assert_eq!(
8783                rejection
8784                    .details
8785                    .as_ref()
8786                    .and_then(|details| details.reason.as_deref()),
8787                Some(INVALID_STORED_VALUES_REASON),
8788                "{label}"
8789            );
8790            // The rejection stays readable after a further reopen.
8791            let journal = reopened
8792                .commit_outcome(&stale)
8793                .expect("read outcome")
8794                .expect("journal entry");
8795            assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8796            drop(reopened);
8797            let mut reopened = open(&path);
8798            let reread = reopened
8799                .commit_outcome(&stale)
8800                .expect("read outcome")
8801                .expect("journal entry");
8802            assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8803
8804            // The legacy row's optimistic projection is gone, the successor
8805            // survives and still encodes, and the round path keeps the queue.
8806            assert_eq!(
8807                reopened
8808                    .query("SELECT id FROM tasks ORDER BY id", &[])
8809                    .unwrap(),
8810                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8811                "{label}"
8812            );
8813            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8814            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8815            assert!(!reopened
8816                .drop_unencodable_outbox(false, None)
8817                .expect("idempotent prepass"));
8818            let mut transport = CountingRealtimeTransport::default();
8819            let outcome = reopened.sync(&mut transport);
8820            assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8821            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8822            let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8823            assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8824            assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8825            drop(reopened);
8826            let _ = std::fs::remove_file(&path);
8827        }
8828    }
8829
8830    #[test]
8831    fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8832        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8833            {"name":"id","type":"string","nullable":false},
8834            {"name":"project_id","type":"string","nullable":false},
8835            {"name":"payload","type":"bytes","nullable":true}],
8836            "scopes":[{"pattern":"project:{project_id}"}]}]});
8837        for (label, column, planted) in [
8838            ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8839            (
8840                "stored null for a non-nullable column",
8841                "project_id",
8842                json!(null),
8843            ),
8844            ("wrong declared type", "payload", json!(7)),
8845        ] {
8846            let path = std::env::temp_dir().join(format!(
8847                "syncular-shared-row-{}-{}.db",
8848                label.replace(' ', "-"),
8849                uuid::Uuid::new_v4()
8850            ));
8851            let open = |path: &std::path::Path| {
8852                SyncClient::open_path(
8853                    "shared-row".into(),
8854                    &schema,
8855                    ClientLimits::default(),
8856                    path.to_str().unwrap(),
8857                )
8858                .expect("open client")
8859            };
8860            let row = |id: &str| {
8861                Map::from_iter([
8862                    ("id".to_owned(), json!(id)),
8863                    ("project_id".to_owned(), json!("p1")),
8864                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8865                ])
8866            };
8867            let upsert = |values| {
8868                vec![Mutation::Upsert {
8869                    table: "tasks".into(),
8870                    values,
8871                    base_version: None,
8872                }]
8873            };
8874            let mut client = open(&path);
8875            client.create_synced_tables().expect("create synced tables");
8876            let older = client.mutate(upsert(row("t1"))).expect("older commit");
8877            let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8878            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8879            drop(client);
8880
8881            let connection = Connection::open(&path).expect("raw connection");
8882            for commit_id in [&older, &newer] {
8883                let mut values = row("t1");
8884                values.insert(column.to_owned(), planted.clone());
8885                let planted_ops = json!([{
8886                    "op": "upsert",
8887                    "table": "tasks",
8888                    "rowId": "t1",
8889                    "baseVersion": null,
8890                    "values": values
8891                }]);
8892                connection
8893                    .execute(
8894                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8895                        rusqlite::params![planted_ops.to_string(), commit_id],
8896                    )
8897                    .expect("plant legacy intent");
8898            }
8899            drop(connection);
8900
8901            // Startup recovery drops both, FIFO, and publishes one batch.
8902            let mut reopened = open(&path);
8903            assert_eq!(
8904                reopened.pending_commit_ids(),
8905                vec![successor.clone()],
8906                "{label}"
8907            );
8908            assert_eq!(
8909                reopened
8910                    .rejections()
8911                    .iter()
8912                    .map(|rejection| rejection.client_commit_id.clone())
8913                    .collect::<Vec<_>>(),
8914                vec![older.clone(), newer.clone()],
8915                "{label}"
8916            );
8917            assert!(
8918                reopened
8919                    .rejections()
8920                    .iter()
8921                    .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8922                        && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8923                            == Some(INVALID_STORED_VALUES_REASON)),
8924                "{label}"
8925            );
8926            for commit_id in [&older, &newer] {
8927                assert!(
8928                    matches!(
8929                        reopened
8930                            .commit_outcome(commit_id)
8931                            .expect("outcome")
8932                            .map(|o| o.status),
8933                        Some(CommitOutcomeStatus::Rejected)
8934                    ),
8935                    "{label}"
8936                );
8937            }
8938            assert_eq!(
8939                reopened
8940                    .query("SELECT id FROM tasks ORDER BY id", &[])
8941                    .unwrap(),
8942                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8943                "{label}"
8944            );
8945            let revision = reopened.local_revision();
8946            let batches = reopened.drain_change_batches();
8947            assert!(
8948                batches.iter().any(|batch| batch.rejections_changed
8949                    && batch.outcomes_changed
8950                    && batch.tables.iter().any(|change| change.table == "tasks")
8951                    && batch.revision == revision.to_string()),
8952                "{label}: {batches:?}"
8953            );
8954            drop(reopened);
8955
8956            // An ordinary reopen with nothing to recover publishes nothing and
8957            // leaves the revision alone.
8958            let mut stable = open(&path);
8959            assert_eq!(stable.local_revision(), revision, "{label}");
8960            assert!(stable.drain_change_batches().is_empty(), "{label}");
8961            assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8962            drop(stable);
8963            let _ = std::fs::remove_file(&path);
8964        }
8965    }
8966
8967    #[test]
8968    fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8969        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8970            {"name":"id","type":"string","nullable":false},
8971            {"name":"project_id","type":"string","nullable":false},
8972            {"name":"payload","type":"bytes","nullable":true}],
8973            "scopes":[{"pattern":"project:{project_id}"}]}]});
8974        for (label, second_op) in [
8975            (
8976                "delete carrying values",
8977                json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8978            ),
8979            (
8980                "baseVersion beyond the safe bound",
8981                json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8982                    "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8983                    "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8984            ),
8985        ] {
8986            let path = std::env::temp_dir().join(format!(
8987                "syncular-journal-shape-{}-{}.db",
8988                label.replace(' ', "-"),
8989                uuid::Uuid::new_v4()
8990            ));
8991            let open = |path: &std::path::Path| {
8992                SyncClient::open_path(
8993                    "journal-shape".into(),
8994                    &schema,
8995                    ClientLimits::default(),
8996                    path.to_str().unwrap(),
8997                )
8998                .expect("open client")
8999            };
9000            let values = |id: &str| {
9001                Map::from_iter([
9002                    ("id".to_owned(), json!(id)),
9003                    ("project_id".to_owned(), json!("p1")),
9004                    ("payload".to_owned(), json!({"$bytes": "0a"})),
9005                ])
9006            };
9007            let upsert = |values| {
9008                vec![Mutation::Upsert {
9009                    table: "tasks".into(),
9010                    values,
9011                    base_version: None,
9012                }]
9013            };
9014            let mut client = open(&path);
9015            client.create_synced_tables().expect("create synced tables");
9016            let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
9017            drop(client);
9018
9019            let connection = Connection::open(&path).expect("raw connection");
9020            // The first operation triggers the drop with canonical cells (a
9021            // stored null for a non-nullable column); the second one is the
9022            // operation shape the journal reader refuses.
9023            let planted = json!([
9024                {
9025                    "op": "upsert",
9026                    "table": "tasks",
9027                    "rowId": "t1",
9028                    "baseVersion": null,
9029                    "values": {"id": "t1", "project_id": null,
9030                        "payload": {"$bytes": "0a"}}
9031                },
9032                second_op
9033            ]);
9034            connection
9035                .execute(
9036                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9037                    rusqlite::params![planted.to_string(), stale],
9038                )
9039                .expect("plant legacy intent");
9040            drop(connection);
9041
9042            let reopened = open(&path);
9043            let journal = reopened
9044                .commit_outcome(&stale)
9045                .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
9046                .unwrap_or_else(|| panic!("{label}: journal entry"));
9047            assert!(
9048                matches!(journal.status, CommitOutcomeStatus::Rejected),
9049                "{label}"
9050            );
9051            assert!(journal.operations.is_none(), "{label}");
9052            assert!(
9053                reopened
9054                    .commit_outcomes(CommitOutcomeQuery::default())
9055                    .is_ok(),
9056                "{label}: outcome list query"
9057            );
9058            assert!(reopened.pending_commit_ids().is_empty(), "{label}");
9059            drop(reopened);
9060            let _ = std::fs::remove_file(&path);
9061        }
9062    }
9063
9064    #[test]
9065    fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
9066        let schema = |version: i32, extra: bool| {
9067            let mut columns = vec![
9068                json!({"name":"id","type":"string","nullable":false}),
9069                json!({"name":"project_id","type":"string","nullable":false}),
9070                json!({"name":"payload","type":"bytes","nullable":true}),
9071            ];
9072            if extra {
9073                columns.push(json!({"name":"later","type":"string","nullable":true}));
9074            }
9075            json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
9076                "scopes":[{"pattern":"project:{project_id}"}]}]})
9077        };
9078        for (label, bump) in [
9079            ("same-version recreation", false),
9080            ("version-bump reset", true),
9081            ("version-only bump reset", true),
9082        ] {
9083            let path = std::env::temp_dir().join(format!(
9084                "syncular-legacy-reset-{}-{}.db",
9085                label.replace(' ', "-"),
9086                uuid::Uuid::new_v4()
9087            ));
9088            let v1 = schema(1, false);
9089            let open = |schema: &Value, path: &std::path::Path| {
9090                SyncClient::open_path(
9091                    "legacy-reset".into(),
9092                    schema,
9093                    ClientLimits::default(),
9094                    path.to_str().unwrap(),
9095                )
9096                .expect("open client")
9097            };
9098            let row = |id: &str| {
9099                Map::from_iter([
9100                    ("id".to_owned(), json!(id)),
9101                    ("project_id".to_owned(), json!("p1")),
9102                    ("payload".to_owned(), json!({"$bytes": "0a"})),
9103                ])
9104            };
9105            let upsert = |values| {
9106                vec![Mutation::Upsert {
9107                    table: "tasks".into(),
9108                    values,
9109                    base_version: None,
9110                }]
9111            };
9112            let mut client = open(&v1, &path);
9113            client.create_synced_tables().expect("create synced tables");
9114            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
9115            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
9116            drop(client);
9117
9118            let connection = Connection::open(&path).expect("raw connection");
9119            let planted = json!([{
9120                "op": "upsert",
9121                "table": "tasks",
9122                "rowId": "t1",
9123                "baseVersion": null,
9124                "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
9125            }]);
9126            connection
9127                .execute(
9128                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9129                    rusqlite::params![planted.to_string(), stale],
9130                )
9131                .expect("plant legacy intent");
9132            drop(connection);
9133
9134            let target = if bump {
9135                schema(2, label == "version-bump reset")
9136            } else {
9137                v1.clone()
9138            };
9139            let reopened = open(&target, &path);
9140            let rejection = reopened
9141                .rejections()
9142                .iter()
9143                .find(|rejection| rejection.client_commit_id == stale)
9144                .unwrap_or_else(|| panic!("{label}: rejection"));
9145            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9146            assert_eq!(
9147                rejection
9148                    .details
9149                    .as_ref()
9150                    .and_then(|details| details.reason.as_deref()),
9151                Some(INVALID_STORED_VALUES_REASON),
9152                "{label}"
9153            );
9154            assert!(rejection.operation.is_some(), "{label}");
9155            assert_eq!(
9156                reopened.pending_commit_ids(),
9157                vec![successor.clone()],
9158                "{label}"
9159            );
9160            // The successor survives and still encodes. It carries §7.1
9161            // insert intent, so after a bump that appends a nullable column it
9162            // replays as a full row with that column NULL.
9163            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
9164            if label == "version-bump reset" {
9165                assert_eq!(
9166                    reopened
9167                        .query("SELECT id, later FROM tasks ORDER BY id", &[])
9168                        .unwrap(),
9169                    vec![Map::from_iter([
9170                        ("id".to_owned(), json!("t2")),
9171                        ("later".to_owned(), Value::Null),
9172                    ])],
9173                    "{label}"
9174                );
9175                // The guarded bump reset owns the publication, so its change
9176                // batch carries the recovery's rejection, outcome, and table
9177                // domains at the revision it advanced to.
9178                let mut next = reopened;
9179                let revision = next.local_revision();
9180                let batches = next.drain_change_batches();
9181                assert!(
9182                    batches.iter().any(|batch| batch.rejections_changed
9183                        && batch.outcomes_changed
9184                        && batch.tables.iter().any(|change| change.table == "tasks")
9185                        && batch.revision == revision.to_string()),
9186                    "{label}: {batches:?}"
9187                );
9188                next.mutate(upsert(Map::from_iter([
9189                    ("id".to_owned(), json!("t3")),
9190                    ("project_id".to_owned(), json!("p1")),
9191                    ("payload".to_owned(), json!({"$bytes": "0b"})),
9192                    ("later".to_owned(), json!("v2")),
9193                ])))
9194                .expect("v2 authoring after the reset");
9195                assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
9196                assert_eq!(next.pending_payloads().len(), 2, "{label}");
9197                drop(next);
9198            } else {
9199                assert_eq!(
9200                    reopened
9201                        .query("SELECT id FROM tasks ORDER BY id", &[])
9202                        .unwrap(),
9203                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9204                    "{label}"
9205                );
9206                drop(reopened);
9207            }
9208
9209            // Queue and corrupt a new commit while the client stays open so only
9210            // recreation can recover it.
9211            if !bump {
9212                let mut recreated = open(&v1, &path);
9213                let fresh = recreated
9214                    .mutate(upsert(row("t3")))
9215                    .expect("fresh commit while open");
9216                recreated.drain_change_batches();
9217                let planted_fresh = json!([{
9218                    "op": "upsert",
9219                    "table": "tasks",
9220                    "rowId": "t3",
9221                    "baseVersion": null,
9222                    "values": {"id": "t3", "project_id": "p1",
9223                        "payload": {"$bytes": "zz"}}
9224                }]);
9225                let connection = Connection::open(&path).expect("raw connection");
9226                let affected = connection
9227                    .execute(
9228                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9229                        rusqlite::params![planted_fresh.to_string(), fresh],
9230                    )
9231                    .expect("plant fresh legacy intent");
9232                assert_eq!(
9233                    affected, 1,
9234                    "{label}: the in-place plant must hit the queued commit"
9235                );
9236                drop(connection);
9237                let revision_before = recreated.local_revision();
9238                recreated
9239                    .recreate_with_schema(&v1)
9240                    .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
9241                assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
9242                let fresh_rejection = recreated
9243                    .rejections()
9244                    .iter()
9245                    .find(|rejection| rejection.client_commit_id == fresh)
9246                    .expect("the in-place recovery rejection");
9247                assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9248                assert_eq!(
9249                    fresh_rejection
9250                        .details
9251                        .as_ref()
9252                        .and_then(|details| details.reason.as_deref()),
9253                    Some(INVALID_STORED_VALUES_REASON),
9254                    "{label}"
9255                );
9256                assert!(
9257                    matches!(
9258                        recreated
9259                            .commit_outcome(&fresh)
9260                            .expect("outcome read")
9261                            .map(|outcome| outcome.status),
9262                        Some(CommitOutcomeStatus::Rejected)
9263                    ),
9264                    "{label}"
9265                );
9266                assert!(recreated.local_revision() > revision_before, "{label}");
9267                let revision = recreated.local_revision();
9268                let batches = recreated.drain_change_batches();
9269                assert_eq!(batches.len(), 1, "{label}: one recovery batch");
9270                assert!(
9271                    batches.iter().any(|batch| batch.rejections_changed
9272                        && batch.outcomes_changed
9273                        && batch.tables.iter().any(|change| change.table == "tasks")
9274                        && batch.revision == revision.to_string()),
9275                    "{label}: {batches:?}"
9276                );
9277                assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
9278                assert_eq!(
9279                    recreated
9280                        .query("SELECT id FROM tasks ORDER BY id", &[])
9281                        .unwrap(),
9282                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9283                    "{label}: the recovered commit leaves no projection"
9284                );
9285                drop(recreated);
9286            }
9287            let _ = std::fs::remove_file(&path);
9288        }
9289    }
9290
9291    #[test]
9292    fn legacy_values_recover_over_a_synced_authoritative_row() {
9293        let path = std::env::temp_dir().join(format!(
9294            "syncular-authoritative-row-{}.db",
9295            uuid::Uuid::new_v4()
9296        ));
9297        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9298            {"name":"id","type":"string","nullable":false},
9299            {"name":"project_id","type":"string","nullable":false},
9300            {"name":"title","type":"string","nullable":false},
9301            {"name":"payload","type":"bytes","nullable":true}],
9302            "scopes":[{"pattern":"project:{project_id}"}]}]});
9303        let open = |path: &std::path::Path| {
9304            SyncClient::open_path(
9305                "authoritative-row".into(),
9306                &schema,
9307                ClientLimits::default(),
9308                path.to_str().unwrap(),
9309            )
9310            .expect("open client")
9311        };
9312        let row = |title: &str| {
9313            Map::from_iter([
9314                ("id".to_owned(), json!("t1")),
9315                ("project_id".to_owned(), json!("p1")),
9316                ("title".to_owned(), json!(title)),
9317                ("payload".to_owned(), json!({"$bytes": "0a"})),
9318            ])
9319        };
9320        let upsert = |values| {
9321            vec![Mutation::Upsert {
9322                table: "tasks".into(),
9323                values,
9324                base_version: None,
9325            }]
9326        };
9327        let mut client = open(&path);
9328        client.create_synced_tables().expect("create synced tables");
9329        // A server-confirmed row: base and visible carry the authoritative
9330        // values at version 1.
9331        client
9332            .conn
9333            .execute_batch(
9334                "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
9335                 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
9336            )
9337            .expect("seed authoritative row");
9338        let older = client.mutate(upsert(row("older"))).expect("older");
9339        let newer = client.mutate(upsert(row("newer"))).expect("newer");
9340        let successor = client.mutate(upsert(row("successor"))).expect("successor");
9341        drop(client);
9342
9343        let connection = Connection::open(&path).expect("raw connection");
9344        for commit_id in [&older, &newer] {
9345            let planted = json!([{
9346                "op": "upsert",
9347                "table": "tasks",
9348                "rowId": "t1",
9349                "baseVersion": null,
9350                "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9351                    "payload": {"$bytes": "zz"}}
9352            }]);
9353            connection
9354                .execute(
9355                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9356                    rusqlite::params![planted.to_string(), commit_id],
9357                )
9358                .expect("plant legacy intent");
9359        }
9360        drop(connection);
9361
9362        let reopened = open(&path);
9363        assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
9364        assert_eq!(
9365            reopened
9366                .rejections()
9367                .iter()
9368                .map(|rejection| rejection.client_commit_id.clone())
9369                .collect::<Vec<_>>(),
9370            vec![older.clone(), newer.clone()]
9371        );
9372        // The pending successor overlays the restored authoritative row, which
9373        // keeps its server version.
9374        assert_eq!(
9375            reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9376            Ok(vec![Map::from_iter([
9377                ("id".to_owned(), json!("t1")),
9378                ("title".to_owned(), json!("successor")),
9379                ("_syncular_version".to_owned(), json!(1)),
9380            ])])
9381        );
9382
9383        // A later rejection of the surviving successor restores the
9384        // authoritative values and version exactly.
9385        drop(reopened);
9386        let connection = Connection::open(&path).expect("raw connection");
9387        connection
9388            .execute(
9389                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9390                rusqlite::params![
9391                    json!([{
9392                        "op": "upsert",
9393                        "table": "tasks",
9394                        "rowId": "t1",
9395                        "baseVersion": null,
9396                        "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9397                            "payload": {"$bytes": "zz"}}
9398                    }])
9399                    .to_string(),
9400                    successor
9401                ],
9402            )
9403            .expect("plant successor intent");
9404        drop(connection);
9405        let late = open(&path);
9406        assert!(late.pending_commit_ids().is_empty());
9407        assert_eq!(
9408            late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9409            Ok(vec![Map::from_iter([
9410                ("id".to_owned(), json!("t1")),
9411                ("title".to_owned(), json!("authoritative")),
9412                ("_syncular_version".to_owned(), json!(1)),
9413            ])])
9414        );
9415        drop(late);
9416        let _ = std::fs::remove_file(&path);
9417    }
9418
9419    #[test]
9420    fn a_failed_value_recovery_commit_restores_every_side_effect() {
9421        let path = std::env::temp_dir().join(format!(
9422            "syncular-value-recovery-commit-{}.db",
9423            uuid::Uuid::new_v4()
9424        ));
9425        let path_str = path.to_str().expect("path");
9426        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9427            {"name":"id","type":"string","nullable":false},
9428            {"name":"project_id","type":"string","nullable":false},
9429            {"name":"payload","type":"bytes","nullable":true}],
9430            "scopes":[{"pattern":"project:{project_id}"}]}]});
9431        // A rollback-journal replica, so a second read connection can hold
9432        // SHARED across the recovery transaction's COMMIT.
9433        let conn = Connection::open(path_str).expect("replica connection");
9434        let mut client = SyncClient::with_connection(
9435            "value-recovery-commit".into(),
9436            &schema,
9437            ClientLimits::default(),
9438            conn,
9439        )
9440        .expect("replica");
9441        assert_eq!(
9442            client
9443                .conn
9444                .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
9445                .expect("journal mode"),
9446            "delete"
9447        );
9448        client.create_synced_tables().expect("create synced tables");
9449        let stale = client
9450            .mutate(vec![Mutation::Upsert {
9451                table: "tasks".into(),
9452                values: Map::from_iter([
9453                    ("id".to_owned(), json!("t1")),
9454                    ("project_id".to_owned(), json!("p1")),
9455                    ("payload".to_owned(), json!({"$bytes": "0a"})),
9456                ]),
9457                base_version: None,
9458            }])
9459            .expect("queued write");
9460        client.drain_change_batches();
9461        let affected = client
9462            .conn
9463            .execute(
9464                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9465                rusqlite::params![
9466                    json!([{
9467                        "op": "upsert",
9468                        "table": "tasks",
9469                        "rowId": "t1",
9470                        "baseVersion": null,
9471                        "values": {"id": "t1", "project_id": "p1",
9472                            "payload": {"$bytes": "zz"}}
9473                    }])
9474                    .to_string(),
9475                    stale
9476                ],
9477            )
9478            .expect("plant legacy intent");
9479        assert_eq!(affected, 1, "the planted row must exist");
9480        let revision = client.local_revision();
9481        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
9482        let prior_outbox = client.pending_commit_ids();
9483        let prior_rejections = client.rejections().len();
9484        let prior_changes = client.change_queue.len();
9485        let prior_intents = client.sync_intent_queue.len();
9486        let prior_failed = client.failed_commits.len();
9487        // The reader holds SHARED for the whole attempt: the recovery runs to
9488        // its publication and the COMMIT fails.
9489        let reader = Connection::open(path_str).expect("reader");
9490        reader
9491            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9492            .expect("reader snapshot");
9493        client
9494            .conn
9495            .busy_timeout(std::time::Duration::ZERO)
9496            .expect("busy timeout");
9497        let error = client
9498            .recreate_with_schema(&schema)
9499            .expect_err("a blocked COMMIT must refuse the recovery");
9500        assert!(error.starts_with("client.storage_busy:"), "{error}");
9501        assert!(client.conn.is_autocommit(), "the transaction is released");
9502        assert_eq!(client.local_revision(), revision);
9503        assert_eq!(client.pending_commit_ids(), prior_outbox);
9504        assert_eq!(client.rejections().len(), prior_rejections);
9505        assert_eq!(client.change_queue.len(), prior_changes);
9506        assert_eq!(client.sync_intent_queue.len(), prior_intents);
9507        assert_eq!(client.failed_commits.len(), prior_failed);
9508        assert_eq!(
9509            client.query("SELECT * FROM tasks", &[]).expect("rows"),
9510            rows
9511        );
9512        assert!(
9513            client
9514                .commit_outcome(&stale)
9515                .expect("outcome read")
9516                .is_none(),
9517            "the rolled-back recovery kept no outcome"
9518        );
9519        // The retry runs the same publication branch to completion.
9520        reader.execute_batch("ROLLBACK").expect("release reader");
9521        client
9522            .recreate_with_schema(&schema)
9523            .expect("retry after the blocked commit");
9524        assert!(client.pending_commit_ids().is_empty());
9525        assert_eq!(client.rejections().len(), prior_rejections + 1);
9526        assert!(client.local_revision() > revision);
9527        let batches = client.drain_change_batches();
9528        assert!(
9529            batches.iter().any(|batch| batch.rejections_changed
9530                && batch.outcomes_changed
9531                && batch.tables.iter().any(|change| change.table == "tasks")),
9532            "{batches:?}"
9533        );
9534        drop(client);
9535        let _ = std::fs::remove_file(&path);
9536    }
9537
9538    #[cfg(feature = "e2ee")]
9539    #[test]
9540    fn a_failed_recovery_restores_retained_failed_commits() {
9541        let path = std::env::temp_dir().join(format!(
9542            "syncular-value-recovery-retention-{}.db",
9543            uuid::Uuid::new_v4()
9544        ));
9545        let path_str = path.to_str().expect("path");
9546        let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
9547            {"name":"id","type":"string","nullable":false},
9548            {"name":"project_id","type":"string","nullable":false},
9549            {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
9550            "scopes":[{"pattern":"project:{project_id}"}]}]});
9551        let conn = Connection::open(path_str).expect("replica connection");
9552        let mut client = SyncClient::with_connection(
9553            "value-recovery-retention".into(),
9554            &schema,
9555            ClientLimits::default(),
9556            conn,
9557        )
9558        .expect("replica");
9559        client.create_synced_tables().expect("create synced tables");
9560        client.set_retain_failed_commits(true).expect("retention");
9561        // No keys configured: the §5.11 seal fails, so retention is exercised.
9562        client.set_encryption(crate::values::EncryptionConfig::default());
9563        client
9564            .mutate(vec![Mutation::Upsert {
9565                table: "secrets".into(),
9566                values: Map::from_iter([
9567                    ("id".to_owned(), json!("s1")),
9568                    ("project_id".to_owned(), json!("p1")),
9569                    ("note".to_owned(), json!("secret")),
9570                ]),
9571                base_version: None,
9572            }])
9573            .expect("queued write");
9574        client.drain_change_batches();
9575        let revision = client.local_revision();
9576        let prior_outbox = client.pending_commit_ids();
9577        let prior_rejections = client.rejections().len();
9578        let prior_failed = client.failed_commits.len();
9579        let reader = Connection::open(path_str).expect("reader");
9580        reader
9581            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9582            .expect("reader snapshot");
9583        client
9584            .conn
9585            .busy_timeout(std::time::Duration::ZERO)
9586            .expect("busy timeout");
9587        let error = client
9588            .drop_unencodable_outbox(true, None)
9589            .expect_err("a blocked COMMIT must refuse the recovery");
9590        assert!(error.starts_with("client.storage_busy:"), "{error}");
9591        assert_eq!(client.failed_commits.len(), prior_failed);
9592        assert_eq!(client.pending_commit_ids(), prior_outbox);
9593        assert_eq!(client.rejections().len(), prior_rejections);
9594        assert_eq!(client.local_revision(), revision);
9595        // The retry retains the failed commit and drops it from the outbox.
9596        reader.execute_batch("ROLLBACK").expect("release reader");
9597        client
9598            .drop_unencodable_outbox(true, None)
9599            .expect("retry after the blocked commit");
9600        assert_eq!(client.failed_commits.len(), prior_failed + 1);
9601        assert!(client.pending_commit_ids().is_empty());
9602        assert_eq!(client.rejections().len(), prior_rejections + 1);
9603        drop(client);
9604        let _ = std::fs::remove_file(&path);
9605    }
9606
9607    #[test]
9608    fn client_error_maps_legacy_codes_and_static_messages() {
9609        let code_only = ClientError::from("sync.unknown_table".to_owned());
9610        assert_eq!(code_only.code, "sync.unknown_table");
9611        assert_eq!(code_only.message, "the commit targets an unknown table");
9612        assert!(code_only.details.is_none());
9613
9614        let dynamic = ClientError::from(
9615            "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
9616        );
9617        assert_eq!(dynamic.code, "sync.invalid_request");
9618        assert_eq!(dynamic.message, "the authoring request is invalid");
9619        assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
9620            .as_str()
9621            .unwrap()
9622            .contains("patch cannot write scope column"));
9623
9624        let unclassified =
9625            ClientError::from("a commit must contain at least one operation".to_owned());
9626        assert_eq!(unclassified.code, "client.failed");
9627        assert_eq!(unclassified.message, "the local authoring operation failed");
9628        assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
9629            .as_str()
9630            .unwrap()
9631            .contains("at least one operation"));
9632
9633        // An unclassified QueryReadFailure keeps its numeric SQLite evidence.
9634        let metadata = ClientError::from(QueryReadFailure {
9635            code: None,
9636            sqlite_code: Some(1),
9637            sqlite_message: Some("generic SQLite prose".to_owned()),
9638            rollback_failure: Some(Box::new(QueryReadFailure::from(
9639                rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
9640            ))),
9641            message: "unclassified read".to_owned(),
9642        });
9643        assert_eq!(metadata.code, "client.failed");
9644        assert_eq!(metadata.message, "the local authoring operation failed");
9645        let details = metadata.details.as_ref().unwrap();
9646        assert_eq!(details["sqliteCode"], 1);
9647        assert_eq!(details["sqliteMessage"], "generic SQLite prose");
9648        assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
9649        assert_eq!(details["legacyCause"], "unclassified read");
9650    }
9651
9652    #[test]
9653    fn schema_marker_read_keeps_storage_classification() {
9654        let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9655            rusqlite::ffi::Error::new(5),
9656            Some("database is locked".to_owned()),
9657        ));
9658        assert!(busy.starts_with("client.storage_busy:"), "{busy}");
9659        let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9660            rusqlite::ffi::Error::new(1),
9661            Some("unreadable".to_owned()),
9662        ));
9663        assert_eq!(
9664            invalid,
9665            "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
9666        );
9667
9668        // A real exclusive lock on the marker read surfaces BUSY, not corrupt,
9669        // and the guard writes nothing.
9670        let path =
9671            std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
9672        let setup = Connection::open(&path).expect("setup connection");
9673        setup
9674            .execute_batch(
9675                "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n                 INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
9676            )
9677            .unwrap();
9678        let locker = Connection::open(&path).expect("locker connection");
9679        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9680        let reader = Connection::open(&path).expect("reader connection");
9681        reader.busy_timeout(std::time::Duration::ZERO).unwrap();
9682        let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
9683        assert!(locked.starts_with("client.storage_busy:"), "{locked}");
9684        locker.execute_batch("ROLLBACK").unwrap();
9685        assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
9686        let rows = setup
9687            .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
9688                row.get::<_, i64>(0)
9689            })
9690            .unwrap();
9691        assert_eq!(rows, 1, "the guard performed no writes");
9692        drop(reader);
9693        drop(locker);
9694        drop(setup);
9695        let _ = std::fs::remove_file(&path);
9696    }
9697
9698    #[cfg(feature = "crdt-yjs")]
9699    #[test]
9700    fn crdt_authoring_pre_read_preserves_storage_classification() {
9701        let path =
9702            std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
9703        let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
9704            {"name":"id","type":"string","nullable":false},
9705            {"name":"project_id","type":"string","nullable":false},
9706            {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
9707            "scopes":[{"pattern":"project:{project_id}"}]}]});
9708        let mut client = SyncClient::open_path(
9709            "crdt-busy".into(),
9710            &schema,
9711            ClientLimits::default(),
9712            path.to_str().unwrap(),
9713        )
9714        .expect("open client");
9715        client.create_synced_tables().expect("create synced tables");
9716        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
9717
9718        let locker = Connection::open(&path).expect("locker connection");
9719        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9720        let busy = client
9721            .crdt_apply_update("notes", "n1", "doc", &[])
9722            .expect_err("busy crdt pre-read");
9723        assert_eq!(busy.code, "client.storage_busy");
9724        assert!(busy.retryable);
9725        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
9726        assert!(client.pending_commit_ids().is_empty());
9727        assert_eq!(client.local_revision(), 0);
9728
9729        locker.execute_batch("ROLLBACK").unwrap();
9730        drop(locker);
9731        drop(client);
9732        let _ = std::fs::remove_file(&path);
9733    }
9734
9735    #[test]
9736    fn large_unique_import_copies_no_unrelated_tables() {
9737        let (mut client, image, table) = unique_import_fixture(10240);
9738        let pending = client.pending_commit_ids();
9739        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
9740            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
9741        assert!(matches!(
9742            client.apply_sqlite_image(
9743                &image,
9744                &table,
9745                true,
9746                &[("project_id".into(), vec!["p1".into()])],
9747                10240,
9748                7,
9749                "digest"
9750            ),
9751            Ok(10240)
9752        ));
9753        assert_eq!(client.overlay_rebuild_count.get(), 0);
9754        assert_eq!(client.pending_commit_ids(), pending);
9755        assert_eq!(
9756            client
9757                .query("SELECT count(*) AS n FROM tasks", &[])
9758                .unwrap()[0]["n"],
9759            10241
9760        );
9761        assert_eq!(
9762            client
9763                .query("SELECT count(*) AS n FROM tasks_fts", &[])
9764                .unwrap()[0]["n"],
9765            10241
9766        );
9767        assert_eq!(
9768            client
9769                .query("SELECT count(*) AS n FROM notes_fts", &[])
9770                .unwrap()[0]["n"],
9771            8192
9772        );
9773    }
9774
9775    #[test]
9776    #[ignore = "manual scaling benchmark; see bench/README.md"]
9777    fn benchmark_unique_image_import() {
9778        let mut samples = Vec::new();
9779        for rows in [10240, 51200, 102400] {
9780            for trial in 0..4 {
9781                let (mut client, image, table) = unique_import_fixture(rows);
9782                let start = std::time::Instant::now();
9783                assert!(
9784                    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)
9785                );
9786                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9787                assert_eq!(
9788                    client
9789                        .query("SELECT count(*) AS n FROM tasks", &[])
9790                        .unwrap()[0]["n"],
9791                    rows + 1
9792                );
9793                assert_eq!(
9794                    client
9795                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
9796                        .unwrap()[0]["n"],
9797                    rows + 1
9798                );
9799                assert_eq!(
9800                    client
9801                        .query("SELECT count(*) AS n FROM notes_fts", &[])
9802                        .unwrap()[0]["n"],
9803                    8192
9804                );
9805                assert_eq!(client.pending_commit_ids().len(), 1);
9806                if trial > 0 {
9807                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9808                }
9809            }
9810        }
9811        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});
9812        let output = serde_json::to_string_pretty(&artifact).unwrap();
9813        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9814            use std::io::Write;
9815            let mut file = std::fs::OpenOptions::new()
9816                .write(true)
9817                .create_new(true)
9818                .open(path)
9819                .unwrap();
9820            file.write_all(output.as_bytes()).unwrap();
9821        } else {
9822            println!("{output}");
9823        }
9824    }
9825
9826    #[test]
9827    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9828        let schema = json!({
9829            "version": 1,
9830            "tables": [{
9831                "name": "catalogue_codes",
9832                "primaryKey": "id",
9833                "columns": [
9834                    { "name": "id", "type": "string", "nullable": false },
9835                    { "name": "release_id", "type": "string", "nullable": false },
9836                    { "name": "code", "type": "string", "nullable": false },
9837                    { "name": "title", "type": "string", "nullable": false }
9838                ],
9839                "scopes": [{ "pattern": "release:{release_id}" }],
9840                "ftsIndexes": [{
9841                    "name": "catalogue_codes_fts",
9842                    "columns": ["code", "title"],
9843                    "tokenize": "unicode61 remove_diacritics 2"
9844                }]
9845            }]
9846        });
9847        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9848            .expect("FTS5 client");
9849        let insert_trigger: String = client
9850            .conn
9851            .query_row(
9852                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9853                [],
9854                |row| row.get(0),
9855            )
9856            .expect("insert trigger");
9857        let replace_guard: String = client
9858            .conn
9859            .query_row(
9860                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9861                [],
9862                |row| row.get(0),
9863            )
9864            .expect("replace guard");
9865        assert!(!insert_trigger.contains("DELETE FROM"));
9866        assert!(replace_guard.contains("BEFORE INSERT"));
9867        assert!(replace_guard.contains("WHEN EXISTS"));
9868        let search = |client: &SyncClient, query: &str| {
9869            client
9870                .query(
9871                    "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",
9872                    &[Value::from(query)],
9873                )
9874                .expect("FTS query")
9875        };
9876
9877        client
9878            .mutate(vec![Mutation::Upsert {
9879                table: "catalogue_codes".to_owned(),
9880                values: Map::from_iter([
9881                    ("id".to_owned(), Value::from("c1")),
9882                    ("release_id".to_owned(), Value::from("r1")),
9883                    ("code".to_owned(), Value::from("A01")),
9884                    ("title".to_owned(), Value::from("Cholera")),
9885                ]),
9886                base_version: None,
9887            }])
9888            .expect("insert code");
9889        assert_eq!(search(&client, "cholera").len(), 1);
9890        let prior = client
9891            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9892            .unwrap();
9893        client
9894            .conn
9895            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9896            .unwrap();
9897        client.create_synced_tables().unwrap();
9898        assert_eq!(
9899            client
9900                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9901                .unwrap(),
9902            prior
9903        );
9904        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();
9905        let rendered = serde_json::to_string(&plan).unwrap();
9906        assert!(rendered.contains("COVERING INDEX"));
9907        assert!(rendered.contains("INDEX 0:="));
9908        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9909        assert_eq!(search(&client, "cholera").len(), 1);
9910
9911        client
9912            .mutate(vec![Mutation::Upsert {
9913                table: "catalogue_codes".to_owned(),
9914                values: Map::from_iter([
9915                    ("id".to_owned(), Value::from("c1")),
9916                    ("release_id".to_owned(), Value::from("r1")),
9917                    ("code".to_owned(), Value::from("A01")),
9918                    ("title".to_owned(), Value::from("Enteric infection")),
9919                ]),
9920                base_version: None,
9921            }])
9922            .expect("update code");
9923        assert!(search(&client, "cholera").is_empty());
9924        assert_eq!(search(&client, "enteric").len(), 1);
9925
9926        client
9927            .mutate(vec![Mutation::Delete {
9928                table: "catalogue_codes".to_owned(),
9929                row_id: "c1".to_owned(),
9930                base_version: None,
9931            }])
9932            .expect("delete code");
9933        assert!(search(&client, "enteric").is_empty());
9934    }
9935
9936    #[test]
9937    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9938        let schema = json!({
9939            "version": 1,
9940            "tables": [{
9941                "name": "patient_notes",
9942                "primaryKey": "id",
9943                "columns": [
9944                    { "name": "id", "type": "string", "nullable": false },
9945                    { "name": "practice_id", "type": "string", "nullable": false },
9946                    { "name": "encryption_key_id", "type": "string", "nullable": false },
9947                    { "name": "title", "type": "string", "nullable": false }
9948                ],
9949                "scopes": [{ "pattern": "practice:{practice_id}" }],
9950                "ftsIndexes": [{
9951                    "name": "patient_notes_fts",
9952                    "columns": ["title"],
9953                    "tokenize": "unicode61 remove_diacritics 2"
9954                }]
9955            }]
9956        });
9957        let mut client = SyncClient::new(
9958            "local-purge-test".to_owned(),
9959            &schema,
9960            ClientLimits::default(),
9961        )
9962        .expect("local purge client");
9963        client
9964            .conn
9965            .execute(
9966                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9967                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9968                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9969                [],
9970            )
9971            .expect("seed base rows");
9972        client.overlay_dirty.set(true);
9973        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9974
9975        let note = |id: &str, key_id: &str, title: &str| {
9976            Map::from_iter([
9977                ("id".to_owned(), Value::from(id)),
9978                ("practice_id".to_owned(), Value::from("practice-1")),
9979                ("encryption_key_id".to_owned(), Value::from(key_id)),
9980                ("title".to_owned(), Value::from(title)),
9981            ])
9982        };
9983        let doomed = client
9984            .mutate(vec![
9985                Mutation::Upsert {
9986                    table: "patient_notes".to_owned(),
9987                    values: note("target", "key-revoked", "Target changed"),
9988                    base_version: None,
9989                },
9990                Mutation::Upsert {
9991                    table: "patient_notes".to_owned(),
9992                    values: note("unrelated", "key-held", "Unrelated changed"),
9993                    base_version: None,
9994                },
9995            ])
9996            .expect("doomed commit");
9997        let kept = client
9998            .mutate(vec![Mutation::Upsert {
9999                table: "patient_notes".to_owned(),
10000                values: note("kept", "key-held", "Kept optimistic"),
10001                base_version: None,
10002            }])
10003            .expect("kept commit");
10004        client.drain_change_batches();
10005
10006        let input = LocalDataPurgeInput {
10007            purge_id: "purge-001".to_owned(),
10008            targets: vec![LocalDataPurgeTarget {
10009                table: "patient_notes".to_owned(),
10010                selectors: BTreeMap::from([(
10011                    "encryption_key_id".to_owned(),
10012                    vec!["key-revoked".to_owned()],
10013                )]),
10014            }],
10015        };
10016        assert_eq!(
10017            client.purge_local_data(&input).expect("apply purge"),
10018            LocalDataPurgeResult {
10019                already_applied: false,
10020                purged_rows: 1,
10021                dropped_commits: 1,
10022            }
10023        );
10024        let rows = client
10025            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
10026            .expect("visible rows");
10027        assert_eq!(rows.len(), 2);
10028        assert_eq!(rows[0]["id"], "kept");
10029        assert_eq!(rows[1]["id"], "unrelated");
10030        assert_eq!(rows[1]["title"], "Unrelated original");
10031        let fts = client
10032            .query(
10033                "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'",
10034                &[],
10035            )
10036            .expect("fts query");
10037        assert!(fts.is_empty());
10038        assert_eq!(client.outbox.len(), 1);
10039        assert_eq!(client.outbox[0].client_commit_id, kept);
10040        let outcome = client
10041            .commit_outcome(&doomed)
10042            .expect("read doomed outcome")
10043            .expect("doomed outcome");
10044        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
10045        match &outcome.results[0] {
10046            CommitOperationOutcome::Error { rejection } => {
10047                assert_eq!(rejection.code, "client.local_data_purged");
10048                assert_eq!(rejection.client_commit_id, doomed);
10049            }
10050            other => panic!("expected local purge rejection, got {other:?}"),
10051        }
10052        assert_eq!(client.drain_change_batches().len(), 1);
10053        assert_eq!(
10054            client.purge_local_data(&input).expect("retry purge"),
10055            LocalDataPurgeResult {
10056                already_applied: true,
10057                purged_rows: 0,
10058                dropped_commits: 0,
10059            }
10060        );
10061        let conflicting = LocalDataPurgeInput {
10062            purge_id: input.purge_id.clone(),
10063            targets: vec![LocalDataPurgeTarget {
10064                table: "patient_notes".to_owned(),
10065                selectors: BTreeMap::from([(
10066                    "encryption_key_id".to_owned(),
10067                    vec!["key-held".to_owned()],
10068                )]),
10069            }],
10070        };
10071        assert!(client
10072            .purge_local_data(&conflicting)
10073            .expect_err("id collision must fail")
10074            .contains("already used with a different plan"));
10075    }
10076
10077    /// Mirror `SyncClient::sync` through the split round API.
10078    fn sync_split(client: &mut SyncClient, transport: &mut dyn Transport) -> SyncOutcome {
10079        let mut next = match client.prepare_sync_round(transport.supports_url_fetch()) {
10080            Ok(prepared) => prepared,
10081            Err(outcome) => return *outcome,
10082        };
10083        loop {
10084            let completed = crate::round::test_support::drive_round(next, transport);
10085            match client.apply_sync_round(completed) {
10086                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10087                crate::AppliedSyncRound::Complete {
10088                    outcome, controls, ..
10089                } => {
10090                    if client.transport_enabled() {
10091                        for text in controls {
10092                            let _ = transport.realtime_send(&text);
10093                        }
10094                    } else {
10095                        client.disconnect_realtime(transport);
10096                    }
10097                    return outcome;
10098                }
10099            }
10100        }
10101    }
10102
10103    #[derive(Clone)]
10104    enum RoundReplyScript {
10105        Response(Message),
10106        /// Answer every pushed commit with an applied result.
10107        PushAck,
10108        Error(TransportError),
10109    }
10110
10111    /// Serves one scripted reply and records every request, segment batch, and
10112    /// upload the round makes.
10113    struct SplitParityTransport {
10114        script: RoundReplyScript,
10115        segments: BTreeMap<String, Vec<u8>>,
10116        requests: Vec<Message>,
10117        batches: Vec<Vec<String>>,
10118        uploads: Vec<String>,
10119        sync_calls: usize,
10120        realtime_sync_calls: usize,
10121    }
10122
10123    impl SplitParityTransport {
10124        fn new(script: RoundReplyScript) -> Self {
10125            Self {
10126                script,
10127                segments: BTreeMap::new(),
10128                requests: Vec::new(),
10129                batches: Vec::new(),
10130                uploads: Vec::new(),
10131                sync_calls: 0,
10132                realtime_sync_calls: 0,
10133            }
10134        }
10135
10136        fn with_segments(script: RoundReplyScript, segments: BTreeMap<String, Vec<u8>>) -> Self {
10137            Self {
10138                segments,
10139                ..Self::new(script)
10140            }
10141        }
10142
10143        fn respond(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10144            let message =
10145                decode_message(request).expect("the client encodes a legal round request");
10146            self.requests.push(message.clone());
10147            match &self.script {
10148                RoundReplyScript::Response(response) => Ok(encode_message(response)),
10149                RoundReplyScript::Error(error) => Err(error.clone()),
10150                RoundReplyScript::PushAck => {
10151                    let mut frames = vec![Frame::RespHeader {
10152                        required_schema_version: None,
10153                        latest_schema_version: None,
10154                        log_epoch: Some("epoch-1".into()),
10155                        reset_required: Some(false),
10156                    }];
10157                    let mut commit_seq = 0;
10158                    for frame in &message.frames {
10159                        if let Frame::PushCommit {
10160                            client_commit_id,
10161                            operations,
10162                        } = frame
10163                        {
10164                            commit_seq += 1;
10165                            frames.push(Frame::PushResult {
10166                                client_commit_id: client_commit_id.clone(),
10167                                status: PushStatus::Applied,
10168                                commit_seq: Some(commit_seq),
10169                                results: (0..operations.len())
10170                                    .map(|op_index| OpResult::Applied {
10171                                        op_index: op_index as i32,
10172                                    })
10173                                    .collect(),
10174                            });
10175                        }
10176                    }
10177                    frames.push(Frame::SubStart {
10178                        id: "tasks".into(),
10179                        status: SubStatus::Active,
10180                        reason_code: String::new(),
10181                        effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10182                        bootstrap: false,
10183                    });
10184                    frames.push(Frame::SubEnd {
10185                        next_cursor: 0,
10186                        bootstrap_state: None,
10187                    });
10188                    Ok(encode_message(&Message {
10189                        wire_version: WIRE_VERSION,
10190                        msg_kind: MsgKind::Response,
10191                        frames,
10192                    }))
10193                }
10194            }
10195        }
10196    }
10197
10198    impl Transport for SplitParityTransport {
10199        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10200            self.sync_calls += 1;
10201            self.respond(request)
10202        }
10203
10204        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10205            self.realtime_sync_calls += 1;
10206            self.respond(request)
10207        }
10208        fn download_segment(
10209            &mut self,
10210            _request: &SegmentRequest,
10211            _on_progress: &mut dyn FnMut(u64),
10212        ) -> Result<Vec<u8>, TransportError> {
10213            Err(TransportError::new(
10214                "sync.transport_failed",
10215                "per-descriptor fetch",
10216            ))
10217        }
10218
10219        fn download_segments(
10220            &mut self,
10221            fetches: &[crate::SegmentFetch],
10222            on_progress: &mut (dyn FnMut(usize, u64) + Send),
10223        ) -> Vec<Result<Vec<u8>, TransportError>> {
10224            self.batches.push(
10225                fetches
10226                    .iter()
10227                    .map(|fetch| match fetch {
10228                        crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
10229                        crate::SegmentFetch::Url(url) => url.clone(),
10230                    })
10231                    .collect(),
10232            );
10233            fetches
10234                .iter()
10235                .enumerate()
10236                .map(|(index, fetch)| {
10237                    let crate::SegmentFetch::Direct(request) = fetch else {
10238                        panic!("the descriptors are direct-endpoint requests")
10239                    };
10240                    let bytes =
10241                        self.segments
10242                            .get(&request.segment_id)
10243                            .cloned()
10244                            .ok_or_else(|| {
10245                                TransportError::new("transport.not_found", "missing segment body")
10246                            })?;
10247                    on_progress(index, bytes.len() as u64);
10248                    Ok(bytes)
10249                })
10250                .collect()
10251        }
10252
10253        fn blob_upload_grant(
10254            &mut self,
10255            _blob_id: &str,
10256            _byte_length: u64,
10257            _media_type: Option<&str>,
10258        ) -> Result<BlobUploadGrant, TransportError> {
10259            Ok(BlobUploadGrant::None)
10260        }
10261
10262        fn blob_upload(
10263            &mut self,
10264            blob_id: &str,
10265            _bytes: &[u8],
10266            _media_type: Option<&str>,
10267        ) -> Result<(), TransportError> {
10268            self.uploads.push(blob_id.to_owned());
10269            Ok(())
10270        }
10271
10272        fn realtime_connect(&mut self) -> Result<(), TransportError> {
10273            Ok(())
10274        }
10275
10276        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
10277            Ok(())
10278        }
10279
10280        fn realtime_close(&mut self) -> Result<(), TransportError> {
10281            Ok(())
10282        }
10283    }
10284
10285    fn parity_clients() -> (SyncClient, SyncClient) {
10286        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10287            {"name":"id","type":"string","nullable":false},
10288            {"name":"project_id","type":"string","nullable":false}],
10289            "scopes":[{"pattern":"project:{project_id}"}]}]});
10290        let [exchange, split] = ["parity-exchange", "parity-split"].map(|id| {
10291            let mut client =
10292                SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10293            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10294            client
10295                .subscribe(
10296                    "tasks".into(),
10297                    "tasks".into(),
10298                    vec![("project_id".into(), vec!["p1".into()])],
10299                    None,
10300                )
10301                .unwrap();
10302            client.subs[0].cursor = 0;
10303            client.subs[0].synced_once = true;
10304            client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
10305            client.persist_sub(&client.subs[0]).unwrap();
10306            client.drain_change_batches();
10307            client
10308        });
10309        (exchange, split)
10310    }
10311
10312    fn pull_response(client: &SyncClient) -> Message {
10313        let row = encode_row_json(
10314            client.schema.table("tasks").unwrap(),
10315            "kept",
10316            &Map::from_iter([
10317                ("id".into(), json!("kept")),
10318                ("project_id".into(), json!("p1")),
10319            ]),
10320            &client.encryption,
10321        )
10322        .unwrap();
10323        Message {
10324            wire_version: WIRE_VERSION,
10325            msg_kind: MsgKind::Response,
10326            frames: vec![
10327                Frame::RespHeader {
10328                    required_schema_version: None,
10329                    latest_schema_version: None,
10330                    log_epoch: Some("epoch-1".into()),
10331                    reset_required: Some(false),
10332                },
10333                Frame::SubStart {
10334                    id: "tasks".into(),
10335                    status: SubStatus::Active,
10336                    reason_code: String::new(),
10337                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10338                    bootstrap: false,
10339                },
10340                Frame::Commit {
10341                    commit_seq: 1,
10342                    created_at_ms: 1,
10343                    actor_id: "writer".into(),
10344                    tables: vec!["tasks".into()],
10345                    changes: vec![ssp2::model::Change {
10346                        table_index: 0,
10347                        row_id: "kept".into(),
10348                        op: Op::Upsert,
10349                        row_version: Some(1),
10350                        scopes: vec![("project_id".into(), "p1".into())],
10351                        row: Some(row),
10352                    }],
10353                },
10354                Frame::SubEnd {
10355                    next_cursor: 1,
10356                    bootstrap_state: None,
10357                },
10358            ],
10359        }
10360    }
10361
10362    #[test]
10363    fn split_round_matches_exchange_for_a_pull() {
10364        let (mut exchange, mut split) = parity_clients();
10365        let response = pull_response(&exchange);
10366        let mut exchange_transport =
10367            SplitParityTransport::new(RoundReplyScript::Response(response.clone()));
10368        let mut split_transport = SplitParityTransport::new(RoundReplyScript::Response(response));
10369
10370        let exchange_outcome = exchange.sync(&mut exchange_transport);
10371        let split_outcome = sync_split(&mut split, &mut split_transport);
10372        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10373        assert_eq!(
10374            exchange
10375                .query("SELECT id, project_id FROM tasks", &[])
10376                .unwrap(),
10377            split
10378                .query("SELECT id, project_id FROM tasks", &[])
10379                .unwrap()
10380        );
10381        assert_eq!(exchange_transport.requests.len(), 1);
10382        assert_eq!(split_transport.requests.len(), 1);
10383    }
10384
10385    #[test]
10386    fn split_round_matches_exchange_for_a_push_with_an_upload() {
10387        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
10388            {"name":"id","type":"string","nullable":false},
10389            {"name":"project_id","type":"string","nullable":false},
10390            {"name":"file","type":"blob_ref","nullable":true}],
10391            "scopes":[{"pattern":"project:{project_id}"}]}]});
10392        let [mut exchange, mut split] = ["parity-push-exchange", "parity-push-split"].map(|id| {
10393            let mut client =
10394                SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10395            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10396            client
10397                .subscribe(
10398                    "attachments".into(),
10399                    "attachments".into(),
10400                    vec![("project_id".into(), vec!["p1".into()])],
10401                    None,
10402                )
10403                .unwrap();
10404            client
10405        });
10406        let bytes = b"split upload";
10407        let mut mutations = Vec::new();
10408        for client in [&mut exchange, &mut split] {
10409            client
10410                .upload_blob(bytes, Some("text/plain".into()), None)
10411                .unwrap();
10412            mutations.push(
10413                client
10414                    .mutate(vec![Mutation::Upsert {
10415                        table: "attachments".into(),
10416                        values: Map::from_iter([
10417                            ("id".into(), Value::from("a1")),
10418                            ("project_id".into(), Value::from("p1")),
10419                            (
10420                                "file".into(),
10421                                Value::from(format!(
10422                                    "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
10423                                    blob_id_for(bytes),
10424                                    bytes.len()
10425                                )),
10426                            ),
10427                        ]),
10428                        base_version: None,
10429                    }])
10430                    .unwrap(),
10431            );
10432        }
10433        let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10434        let mut split_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10435        let exchange_outcome = exchange.sync(&mut exchange_transport);
10436        let split_outcome = sync_split(&mut split, &mut split_transport);
10437        let SyncOutcome::Ok(exchange_report) = exchange_outcome else {
10438            panic!("the exchange round must succeed: {exchange_outcome:?}")
10439        };
10440        let SyncOutcome::Ok(split_report) = split_outcome else {
10441            panic!("the split round must succeed: {split_outcome:?}")
10442        };
10443        assert_eq!(exchange_report.pushed, 1);
10444        assert_eq!(split_report.pushed, 1);
10445        assert_eq!(
10446            exchange_report.commits_applied,
10447            split_report.commits_applied
10448        );
10449        assert_eq!(
10450            exchange_transport.uploads,
10451            [blob_id_for(bytes)],
10452            "one upload before the metadata request"
10453        );
10454        assert_eq!(split_transport.uploads, exchange_transport.uploads);
10455        assert!(exchange.pending_commit_ids().is_empty());
10456        assert!(split.pending_commit_ids().is_empty());
10457        assert_eq!(
10458            exchange
10459                .query("SELECT id, project_id FROM attachments", &[])
10460                .unwrap(),
10461            split
10462                .query("SELECT id, project_id FROM attachments", &[])
10463                .unwrap()
10464        );
10465    }
10466
10467    #[test]
10468    fn split_round_matches_exchange_for_a_segment_download() {
10469        let (mut exchange, mut split) = parity_clients();
10470        let columns = exchange.schema.table("tasks").unwrap().wire_columns.clone();
10471        let page = ssp2::segment::encode_rows_segment(&RowsSegment {
10472            table: "tasks".into(),
10473            schema_version: 1,
10474            columns,
10475            blocks: vec![vec![ssp2::segment::SegmentRow {
10476                server_version: 1,
10477                values: vec![
10478                    Some(ColumnValue::String("page".into())),
10479                    Some(ColumnValue::String("p1".into())),
10480                ],
10481            }]],
10482        });
10483        let segment_id = format!("sha256:{}", bytes_to_hex(&Sha256::digest(&page)));
10484        let response = Message {
10485            wire_version: WIRE_VERSION,
10486            msg_kind: MsgKind::Response,
10487            frames: vec![
10488                Frame::RespHeader {
10489                    required_schema_version: None,
10490                    latest_schema_version: None,
10491                    log_epoch: Some("epoch-1".into()),
10492                    reset_required: Some(false),
10493                },
10494                Frame::SubStart {
10495                    id: "tasks".into(),
10496                    status: SubStatus::Active,
10497                    reason_code: String::new(),
10498                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10499                    bootstrap: true,
10500                },
10501                Frame::SegmentRef {
10502                    segment_id: segment_id.clone(),
10503                    media_type: MediaType::Rows,
10504                    table: "tasks".into(),
10505                    byte_length: page.len() as i64,
10506                    row_count: 1,
10507                    as_of_commit_seq: 1,
10508                    scope_digest: "digest".into(),
10509                    row_cursor: None,
10510                    next_row_cursor: None,
10511                    url: None,
10512                    url_expires_at_ms: None,
10513                },
10514                Frame::SubEnd {
10515                    next_cursor: 1,
10516                    bootstrap_state: None,
10517                },
10518            ],
10519        };
10520        let segments = BTreeMap::from([(segment_id.clone(), page)]);
10521        let mut exchange_transport = SplitParityTransport::with_segments(
10522            RoundReplyScript::Response(response.clone()),
10523            segments.clone(),
10524        );
10525        let mut split_transport =
10526            SplitParityTransport::with_segments(RoundReplyScript::Response(response), segments);
10527        let exchange_outcome = exchange.sync(&mut exchange_transport);
10528        let split_outcome = sync_split(&mut split, &mut split_transport);
10529        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10530        assert_eq!(exchange_transport.batches, [vec![segment_id.clone()]]);
10531        assert_eq!(split_transport.batches, exchange_transport.batches);
10532        assert_eq!(
10533            exchange
10534                .query("SELECT id, project_id FROM tasks", &[])
10535                .unwrap(),
10536            split
10537                .query("SELECT id, project_id FROM tasks", &[])
10538                .unwrap()
10539        );
10540    }
10541
10542    #[test]
10543    fn split_round_matches_exchange_for_a_transport_failure() {
10544        let (mut exchange, mut split) = parity_clients();
10545        let error = TransportError::new("transport.timeout", "injected");
10546        let mut exchange_transport =
10547            SplitParityTransport::new(RoundReplyScript::Error(error.clone()));
10548        let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10549        let exchange_outcome = exchange.sync(&mut exchange_transport);
10550        let split_outcome = sync_split(&mut split, &mut split_transport);
10551        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10552        assert!(matches!(
10553            split_outcome,
10554            SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
10555        ));
10556        assert_eq!(
10557            exchange.query("SELECT id FROM tasks", &[]).unwrap(),
10558            split.query("SELECT id FROM tasks", &[]).unwrap()
10559        );
10560    }
10561
10562    #[test]
10563    fn split_round_matches_exchange_for_a_failed_realtime_round() {
10564        let (mut exchange, mut split) = parity_clients();
10565        let error = TransportError::new("transport.timeout", "socket round failed");
10566        let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10567        let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(
10568            TransportError::new("transport.timeout", "socket round failed"),
10569        ));
10570        exchange.connect_realtime(&mut exchange_transport).unwrap();
10571        split.connect_realtime(&mut split_transport).unwrap();
10572        let exchange_outcome = exchange.sync(&mut exchange_transport);
10573        let split_outcome = sync_split(&mut split, &mut split_transport);
10574        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10575        assert_eq!(exchange_transport.realtime_sync_calls, 1);
10576        assert_eq!(exchange_transport.sync_calls, 0);
10577        assert_eq!(split_transport.realtime_sync_calls, 1);
10578        assert_eq!(split_transport.sync_calls, 0);
10579        assert_eq!(exchange.realtime_state(), RealtimeState::Lost);
10580        assert_eq!(split.realtime_state(), RealtimeState::Lost);
10581    }
10582}
10583
10584impl SubState {
10585    fn name(self) -> &'static str {
10586        match self {
10587            SubState::Active => "active",
10588            SubState::Revoked => "revoked",
10589            SubState::Failed => "failed",
10590        }
10591    }
10592
10593    fn parse(value: &str) -> Self {
10594        match value {
10595            "revoked" => Self::Revoked,
10596            "failed" => Self::Failed,
10597            _ => Self::Active,
10598        }
10599    }
10600}
10601
10602#[derive(Debug, Clone)]
10603struct Subscription {
10604    id: String,
10605    table: String,
10606    requested: Vec<(String, Vec<String>)>,
10607    params: Option<String>,
10608    cursor: i64,
10609    /// §4.7 resume token, round-tripped opaquely.
10610    bootstrap_state: Option<String>,
10611    state: SubState,
10612    reason_code: Option<String>,
10613    /// Last effective scopes echoed while active (§3.3: persisted for the
10614    /// purge contract; each active echo replaces it).
10615    effective: Option<Vec<(String, Vec<String>)>>,
10616    synced_once: bool,
10617}
10618
10619#[cfg(test)]
10620mod subscription_rearm_tests {
10621    use super::*;
10622    use serde_json::json;
10623
10624    fn client() -> SyncClient {
10625        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10626            {"name":"id","type":"string","nullable":false},
10627            {"name":"project_id","type":"string","nullable":false}],
10628            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
10629        SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
10630    }
10631
10632    fn scopes() -> Vec<(String, Vec<String>)> {
10633        vec![("project_id".to_owned(), vec!["p1".to_owned()])]
10634    }
10635
10636    /// §3.3: the stored registration carries progress the re-arm must drop.
10637    fn store_revoked(client: &mut SyncClient) {
10638        client
10639            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10640            .expect("subscribe");
10641        let sub = &mut client.subs[0];
10642        sub.cursor = 41;
10643        sub.bootstrap_state = Some("resume".to_owned());
10644        sub.effective = Some(scopes());
10645        sub.state = SubState::Revoked;
10646        sub.reason_code = Some("sync.scope_revoked".to_owned());
10647        client.persist_sub(&client.subs[0]).expect("persist");
10648        client.sync_needed = false;
10649        client.sync_intent_queue.clear();
10650    }
10651
10652    #[test]
10653    fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
10654        let mut client = client();
10655        store_revoked(&mut client);
10656
10657        client
10658            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10659            .expect("re-arm");
10660
10661        let view = client.subscription_state("sub").expect("state");
10662        assert_eq!(view.status, "active");
10663        assert_eq!(view.cursor, -1);
10664        assert!(!view.has_resume_token);
10665        assert_eq!(view.effective_scopes, None);
10666        assert_eq!(view.reason_code, None);
10667        assert!(client.sync_needed, "a re-arm asks for a pull");
10668        assert!(matches!(
10669            client.drain_sync_intents().as_slice(),
10670            [SyncIntent::Interactive]
10671        ));
10672        let stored: String = client
10673            .conn
10674            .query_row(
10675                "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
10676                [],
10677                |row| row.get(0),
10678            )
10679            .expect("persisted row");
10680        let stored: Value = serde_json::from_str(&stored).expect("stored json");
10681        assert_eq!(stored["status"], json!("active"));
10682        assert_eq!(stored["cursor"], json!(-1));
10683        assert_eq!(stored["bootstrapState"], Value::Null);
10684        assert_eq!(stored["effectiveScopes"], Value::Null);
10685        assert_eq!(stored["reasonCode"], Value::Null);
10686    }
10687
10688    #[test]
10689    fn a_failed_registration_is_not_rearmed() {
10690        let mut client = client();
10691        client
10692            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10693            .expect("subscribe");
10694        client.subs[0].state = SubState::Failed;
10695        client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
10696        client.persist_sub(&client.subs[0]).expect("persist");
10697        client.sync_needed = false;
10698        client.sync_intent_queue.clear();
10699
10700        client
10701            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10702            .expect("subscribe");
10703
10704        let view = client.subscription_state("sub").expect("state");
10705        assert_eq!(view.status, "failed");
10706        assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
10707        assert!(!client.sync_needed);
10708        assert!(client.drain_sync_intents().is_empty());
10709    }
10710}
10711
10712#[derive(Debug, Clone)]
10713struct OutboxOp {
10714    upsert: bool,
10715    table: String,
10716    row_id: String,
10717    base_version: Option<i64>,
10718    /// Schema-agnostic local form (§0): driver JSON values, encoded with
10719    /// the current codec at send time. A key's presence IS the §6.1 sparse
10720    /// presence set — both the overlay and the wire payload derive from it.
10721    values: Option<Map<String, Value>>,
10722    /// §7.1 insert intent: `mutate` recorded this upsert while the row was
10723    /// absent from the visible replica. Replay and send-time encoding present
10724    /// every current-schema column; a column `values` lacks is NULL.
10725    insert: bool,
10726}
10727
10728impl OutboxOp {
10729    /// The values this upsert presents under the current `table` (§7.1). An
10730    /// insert-intent operation presents every column, a missing one as NULL;
10731    /// `None` when a missing column is non-nullable, which leaves the commit
10732    /// unencodable (§7.4.4), or when the operation carries no values.
10733    fn presented_values(&self, table: &TableSchema) -> Option<Cow<'_, Map<String, Value>>> {
10734        let values = self.values.as_ref()?;
10735        if !self.insert || table.columns.iter().all(|c| values.contains_key(&c.name)) {
10736            return Some(Cow::Borrowed(values));
10737        }
10738        full_row_values(table, values.clone()).ok().map(Cow::Owned)
10739    }
10740}
10741
10742impl From<&OutboxOp> for CommitOperation {
10743    fn from(operation: &OutboxOp) -> Self {
10744        Self {
10745            table: operation.table.clone(),
10746            row_id: operation.row_id.clone(),
10747            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
10748            base_version: operation.base_version,
10749            values: operation.values.clone(),
10750            insert: operation.insert,
10751        }
10752    }
10753}
10754
10755#[derive(Debug, Clone)]
10756struct OutboxCommit {
10757    client_commit_id: String,
10758    ops: Vec<OutboxOp>,
10759}
10760
10761/// One commit's `(table, value keys, insert intent)` per upsert operation — the classification
10762/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
10763/// can never disagree about which commits cannot re-encode.
10764fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>, bool)> {
10765    commit
10766        .ops
10767        .iter()
10768        .filter(|operation| operation.upsert)
10769        .map(|operation| {
10770            (
10771                operation.table.as_str(),
10772                operation
10773                    .values
10774                    .as_ref()
10775                    .map(|values| values.keys().map(String::as_str).collect())
10776                    .unwrap_or_default(),
10777                operation.insert,
10778            )
10779        })
10780        .collect()
10781}
10782
10783type FailedCommit = (OutboxCommit, Map<String, Value>);
10784
10785#[derive(Debug, Clone)]
10786struct CompiledLocalDataPurgeTarget {
10787    table: String,
10788    selectors: Vec<(String, Vec<String>)>,
10789}
10790
10791struct StoredCommitOutcomeRow {
10792    sequence: i64,
10793    client_commit_id: String,
10794    status: String,
10795    recorded_at_ms: i64,
10796    results_json: String,
10797    operations_json: Option<String>,
10798    resolution: String,
10799    resolved_at_ms: Option<i64>,
10800    replacement_client_commit_id: Option<String>,
10801}
10802
10803impl StoredCommitOutcomeRow {
10804    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
10805        Ok(Self {
10806            sequence: row.get(0)?,
10807            client_commit_id: row.get(1)?,
10808            status: row.get(2)?,
10809            recorded_at_ms: row.get(3)?,
10810            results_json: row.get(4)?,
10811            operations_json: row.get(5)?,
10812            resolution: row.get(6)?,
10813            resolved_at_ms: row.get(7)?,
10814            replacement_client_commit_id: row.get(8)?,
10815        })
10816    }
10817}
10818
10819/// Section outcome distinguishing the §5.6 subscription-local fail-closed
10820/// path from a round-aborting failure (§1.4 rule 5).
10821enum SectionError {
10822    FailClosed,
10823    Abort(String, String),
10824}
10825
10826#[derive(Debug)]
10827pub(crate) struct RequestMeta {
10828    pushed_ids: Vec<String>,
10829    /// Subscription id → the request carried `cursor < 0` and no resume
10830    /// token (§5.6 first-page detection: a *fresh* bootstrap).
10831    fresh: Vec<(String, bool)>,
10832    pub(crate) accept: u8,
10833    /// §6.1 splitBatch: outbox commits held back from THIS request because
10834    /// the running operation count reached the push cap — the next round
10835    /// pushes them (`sync_needed` stays set while any remain).
10836    deferred_commits: usize,
10837}
10838
10839#[derive(Default)]
10840struct ChangeAccumulator {
10841    /// None means table-wide; Some is the exact scoped domain.
10842    tables: BTreeMap<String, Option<BTreeSet<String>>>,
10843    windows: BTreeMap<(String, String), BTreeSet<String>>,
10844    status: bool,
10845    conflicts: bool,
10846    rejections: bool,
10847    outcomes: bool,
10848}
10849
10850impl ChangeAccumulator {
10851    fn table(&mut self, table: &str) {
10852        self.tables.insert(table.to_owned(), None);
10853    }
10854
10855    fn scope(&mut self, table: &str, key: String) {
10856        match self.tables.get_mut(table) {
10857            Some(None) => {}
10858            Some(Some(keys)) => {
10859                keys.insert(key);
10860            }
10861            None => {
10862                self.tables
10863                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
10864            }
10865        }
10866    }
10867
10868    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
10869        self.windows
10870            .entry((base_key.to_owned(), table.to_owned()))
10871            .or_default()
10872            .insert(unit.to_owned());
10873    }
10874
10875    fn touched(&self) -> bool {
10876        !self.tables.is_empty()
10877            || !self.windows.is_empty()
10878            || self.status
10879            || self.conflicts
10880            || self.rejections
10881            || self.outcomes
10882    }
10883}
10884
10885/// §6.1: the server caps total operations per request (reference default
10886/// 500) and rejects the whole batch with `sync.too_many_operations`; the
10887/// client "splits and retries". Splitting happens at build time: commits are
10888/// included IN ORDER until the operation budget is spent, the rest wait for
10889/// the next round.
10890const PUSH_OPS_PER_REQUEST: usize = 500;
10891const MAX_LOCAL_PURGE_TARGETS: usize = 64;
10892const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
10893const MAX_LOCAL_PURGE_VALUES: usize = 128;
10894const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
10895pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
10896
10897/// Dirty visible tables; `None` means a whole-replica recovery/reset.
10898struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
10899
10900impl OverlayDirty {
10901    fn clean() -> Self {
10902        Self(RefCell::new(Some(BTreeSet::new())))
10903    }
10904    fn get(&self) -> bool {
10905        self.0
10906            .borrow()
10907            .as_ref()
10908            .is_none_or(|tables| !tables.is_empty())
10909    }
10910    fn set(&self, dirty: bool) {
10911        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
10912    }
10913    fn table(&self, table: &str) {
10914        if let Some(tables) = self.0.borrow_mut().as_mut() {
10915            tables.insert(table.to_owned());
10916        }
10917    }
10918    fn snapshot(&self) -> Option<BTreeSet<String>> {
10919        self.0.borrow().clone()
10920    }
10921    fn restore(&self, tables: Option<BTreeSet<String>>) {
10922        *self.0.borrow_mut() = tables;
10923    }
10924}
10925
10926pub struct SyncClient {
10927    #[cfg(feature = "bench-internals")]
10928    benchmark_phases: Recorder,
10929    conn: Connection,
10930    schema: ClientSchema,
10931    client_id: String,
10932    limits: ClientLimits,
10933    subs: Vec<Subscription>,
10934    outbox: Vec<OutboxCommit>,
10935    failed_commits: Vec<FailedCommit>,
10936    retain_failed_commits: bool,
10937    conflicts: Vec<ConflictRecord>,
10938    rejections: Vec<RejectionRecord>,
10939    schema_floor: Option<SchemaFloor>,
10940    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
10941    lease_state: Option<LeaseState>,
10942    /// §1.6: the schema-floor response stops syncing until an upgrade.
10943    stopped: bool,
10944    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
10945    upgrading: bool,
10946    /// §8.4 coalesced sync-needed signal.
10947    sync_needed: bool,
10948    /// §8.8: how sync rounds treat the realtime binding, and its exact
10949    /// availability state with the evidence behind a lost or refused one.
10950    realtime_policy: RealtimePolicy,
10951    realtime_state: RealtimeState,
10952    realtime_reason_code: Option<String>,
10953    realtime_retry_delay_ms: Option<u64>,
10954    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
10955    presence: HashMap<String, HashMap<String, PresencePeer>>,
10956    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
10957    /// host may pin it (conformance runs on a virtual clock).
10958    now_ms: Option<i64>,
10959    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
10960    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
10961    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
10962    encryption: crate::values::EncryptionConfig,
10963    /// Fail-closed host bootstrap gate. While set, command hosts permit only
10964    /// status/lifecycle inspection and an exact authorized local purge.
10965    security_preflight: bool,
10966    transport_enabled: bool,
10967    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
10968    /// means the feature is off; the descriptor write and the container orphan
10969    /// sweep still happen.
10970    previous_version: Option<PreviousVersionContextConfig>,
10971    /// Per-table primary-key upsert SQL, built once per (full table name) —
10972    /// the row write path runs per row during bootstrap (§5.6), so the SQL
10973    /// string (and, via `prepare_cached`, its compiled statement) is reused
10974    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
10975    /// schema reset (the column lists may have changed).
10976    insert_sql: RefCell<HashMap<String, String>>,
10977    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
10978    /// reset and restart explicitly invalidate every table.
10979    overlay_dirty: OverlayDirty,
10980    // First SQLite failure of the current operation, before savepoint cleanup.
10981    storage_failure: RefCell<Option<QueryReadFailure>>,
10982    /// Test-only structural performance signal: response processing must not
10983    /// turn a batch of acknowledgements into one full overlay rebuild each.
10984    #[cfg(test)]
10985    overlay_rebuild_count: Cell<usize>,
10986    #[cfg(test)]
10987    acknowledged_replay_count: Cell<usize>,
10988    /// Test-only structural performance signal: outcome retention is enforced
10989    /// once per response rather than once per acknowledgement.
10990    #[cfg(test)]
10991    outcome_prune_count: Cell<usize>,
10992    /// Exact observer-transaction output drained by command/FFI hosts.
10993    progress: ProgressObserver,
10994    change_queue: VecDeque<ClientChangeBatch>,
10995    sync_intent_queue: VecDeque<SyncIntent>,
10996    /// Explicit exponential retry policy for transient transport failures.
10997    retry_delay_ms: u64,
10998    /// §7.6: the background retry the current round scheduled, if any.
10999    round_retry_delay_ms: Option<u64>,
11000    /// A round whose response never completed (§1.5): the client keeps its
11001    /// code and reports it on every following request until a round receives a
11002    /// complete, decodable response. The server already recorded the round, so
11003    /// nothing else can tell it the round failed on this side.
11004    round_failure: Option<String>,
11005    active_round: Option<uuid::Uuid>,
11006    last_round: Option<DiagnosticLastRound>,
11007    last_change: Option<DiagnosticLastChange>,
11008    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
11009    query_failures: Vec<DiagnosticQueryFailure>,
11010    /// SPEC §1.4 rule 4: the open coalesced apply transaction of a response.
11011    coalesced: Option<CoalescedApply>,
11012    /// A coalesced apply transaction of the current response failed; every
11013    /// later observation of that response fails until it ends.
11014    coalesced_failure: Option<String>,
11015}
11016
11017/// SPEC §1.4 rule 4: the frames and blocks of one response share one local
11018/// write transaction of at most `COALESCED_APPLY_ROWS` rows. The in-memory
11019/// state captured at its `BEGIN` is restored when its `COMMIT` fails.
11020struct CoalescedApply {
11021    rows: usize,
11022    subs: Vec<Subscription>,
11023    outbox: Vec<OutboxCommit>,
11024    failed_commits: Vec<FailedCommit>,
11025    conflicts: Vec<ConflictRecord>,
11026    rejections: Vec<RejectionRecord>,
11027    schema_floor: Option<SchemaFloor>,
11028    lease_state: Option<LeaseState>,
11029    stopped: bool,
11030    upgrading: bool,
11031    sync_needed: bool,
11032    overlay_dirty: Option<BTreeSet<String>>,
11033    change_queue: usize,
11034    sync_intent_queue: usize,
11035    last_change: Option<DiagnosticLastChange>,
11036}
11037
11038const COALESCED_APPLY_ROWS: usize = 1024;
11039
11040pub(crate) fn quote_ident(name: &str) -> String {
11041    format!("\"{}\"", name.replace('"', "\"\""))
11042}
11043/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
11044/// previous-version module reads and writes the SAME records as the client
11045/// instead of growing a parallel meta layer.
11046pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
11047    conn.query_row(
11048        "SELECT value FROM _syncular_meta WHERE key = ?1",
11049        rusqlite::params![key],
11050        |row| row.get::<_, String>(0),
11051    )
11052    .ok()
11053}
11054
11055pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
11056    let _ = conn.execute(
11057        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
11058        rusqlite::params![key, value],
11059    );
11060}
11061
11062pub(crate) fn meta_delete(conn: &Connection, key: &str) {
11063    let _ = conn.execute(
11064        "DELETE FROM _syncular_meta WHERE key = ?1",
11065        rusqlite::params![key],
11066    );
11067}
11068
11069/// Seek by the existing key while retaining the exact legacy text match.
11070fn row_id_predicate(table: &TableSchema) -> String {
11071    let key = quote_ident(&table.primary_key);
11072    let text_match = format!("CAST({key} AS TEXT) = ?1");
11073    match table.columns[table.pk_index].ty {
11074        ColumnType::String | ColumnType::Json => {
11075            format!("{key} = ?1 AND {text_match}")
11076        }
11077        // Native columns have no declared SQLite affinity. Match the stored
11078        // integer bind type explicitly. Unary + removes expression affinity so
11079        // SQLite can seek the typeless key; the text check preserves exact IDs.
11080        ColumnType::Integer | ColumnType::Boolean => {
11081            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
11082        }
11083        // REAL-to-text conversion can round distinct values to the same text.
11084        // Narrowing those matches by numeric equality would change behavior.
11085        _ => text_match,
11086    }
11087}
11088
11089fn is_local_operation_code_like(value: &str) -> bool {
11090    let bytes = value.as_bytes();
11091    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
11092        && bytes
11093            .iter()
11094            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
11095}
11096
11097fn base_table(name: &str) -> String {
11098    quote_ident(&format!("_syncular_base_{name}"))
11099}
11100
11101/// §4.8: a stable, server-opaque key for a window base — table + variable +
11102/// canonical fixed scopes. Two `set_window` calls with the same base
11103/// address the same registry rows.
11104/// §4.8 deferred eviction record: (sub id, table, effective scope map).
11105type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
11106
11107fn window_base_key(base: &WindowBase) -> String {
11108    format!(
11109        "{}\0{}\0{}",
11110        base.table,
11111        base.variable,
11112        canonical_scope_json(&base.fixed_scopes)
11113    )
11114}
11115
11116/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
11117fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
11118    let mut scopes = base.fixed_scopes.clone();
11119    scopes.retain(|(k, _)| k != &base.variable);
11120    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
11121    scopes
11122}
11123
11124/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
11125/// echoed not interpreted by the server, so the exact hash is client
11126/// convention; SHA-256 matches the SPEC's worked example.
11127fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
11128    let canonical = canonical_scope_json(&unit_scopes(base, unit));
11129    let digest = Sha256::digest(canonical.as_bytes());
11130    let hex = bytes_to_hex(&digest);
11131    format!("w:{}:{}", base.table, &hex[..16])
11132}
11133
11134fn visible_table(name: &str) -> String {
11135    quote_ident(name)
11136}
11137
11138const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
11139
11140/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
11141/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
11142fn is_synced_table_name(name: &str) -> bool {
11143    if name.starts_with("sqlite_") {
11144        return false;
11145    }
11146    if name.starts_with("_syncular_base_") {
11147        return true; // the base half of a synced table pair
11148    }
11149    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
11150    !name.starts_with("_syncular_")
11151}
11152
11153/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
11154fn blob_id_for(bytes: &[u8]) -> String {
11155    let digest = Sha256::digest(bytes);
11156    format!("sha256:{}", bytes_to_hex(&digest))
11157}
11158
11159/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
11160/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
11161/// per row on the §5.6 bootstrap path), scalars bind owned.
11162enum RowParam<'a> {
11163    Cell(&'a Option<ColumnValue>),
11164    Version(i64),
11165}
11166
11167impl rusqlite::ToSql for RowParam<'_> {
11168    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
11169        Ok(match self {
11170            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
11171            RowParam::Cell(cell) => match cell {
11172                None => ToSqlOutput::Owned(SqlValue::Null),
11173                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
11174                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
11175                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
11176                Some(ColumnValue::Boolean(b)) => {
11177                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
11178                }
11179                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
11180                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
11181                }
11182                // §5.10: crdt bytes store as BLOB, like bytes.
11183                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
11184                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
11185                }
11186            },
11187        })
11188    }
11189}
11190
11191// Retain a typed primary key across the current import chunk without copying row payloads.
11192fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
11193    match param.to_sql().map_err(|error| error.to_string())? {
11194        ToSqlOutput::Owned(value) => Ok(value),
11195        ToSqlOutput::Borrowed(value) => {
11196            SqlValue::try_from(value).map_err(|error| error.to_string())
11197        }
11198        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
11199    }
11200}
11201
11202/// §5.3 image cell → bind parameter, strict per the declared column type
11203/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
11204/// nullable). Mismatches are image-producer violations. Returns the cell
11205/// borrowed when the stored representation already matches what the row
11206/// codec would write, or the normalized scalar (boolean → 0/1, float from
11207/// INTEGER → REAL) otherwise — the local write is byte-identical to the
11208/// old convert-then-insert path without allocating per cell.
11209fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
11210    use ssp2::segment::ColumnType;
11211    let mismatch = || {
11212        Err(format!(
11213            "image column {:?} holds a value of the wrong type",
11214            column.name
11215        ))
11216    };
11217    match value {
11218        ValueRef::Null => {
11219            if !column.nullable {
11220                return Err(format!(
11221                    "image column {:?} is NULL but not nullable",
11222                    column.name
11223                ));
11224            }
11225            Ok(ToSqlOutput::Owned(SqlValue::Null))
11226        }
11227        ValueRef::Integer(i) => match column.ty {
11228            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
11229            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
11230            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
11231            _ => mismatch(),
11232        },
11233        ValueRef::Real(_) => match column.ty {
11234            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
11235            _ => mismatch(),
11236        },
11237        ValueRef::Text(t) => {
11238            std::str::from_utf8(t)
11239                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
11240            match column.ty {
11241                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
11242                    Ok(ToSqlOutput::Borrowed(value))
11243                }
11244                _ => mismatch(),
11245            }
11246        }
11247        ValueRef::Blob(_) => match column.ty {
11248            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
11249            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
11250            _ => mismatch(),
11251        },
11252    }
11253}
11254
11255pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
11256    use rusqlite::types::ValueRef;
11257    match value {
11258        ValueRef::Null => Value::Null,
11259        ValueRef::Integer(i) => match column.ty {
11260            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
11261            ssp2::segment::ColumnType::Float => {
11262                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
11263            }
11264            _ => Value::from(i),
11265        },
11266        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11267        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11268        ValueRef::Blob(b) => {
11269            let mut map = Map::new();
11270            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11271            Value::Object(map)
11272        }
11273    }
11274}
11275
11276/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
11277/// Objects are accepted in lossless `{"$bytes": hex}` and
11278/// `{"$bigint": decimal}` envelope forms.
11279fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
11280    Ok(match value {
11281        Value::Null => SqlValue::Null,
11282        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
11283        Value::Number(n) => {
11284            if let Some(i) = n.as_i64() {
11285                SqlValue::Integer(i)
11286            } else if let Some(f) = n.as_f64() {
11287                SqlValue::Real(f)
11288            } else {
11289                return Err(format!("query param number {n} is out of range"));
11290            }
11291        }
11292        Value::String(s) => SqlValue::Text(s.clone()),
11293        Value::Object(_) => {
11294            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
11295                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
11296            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
11297                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
11298                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
11299                })?)
11300            } else {
11301                return Err(
11302                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
11303                        .to_owned(),
11304                );
11305            }
11306        }
11307        Value::Array(_) => return Err("query array params are not supported".to_owned()),
11308    })
11309}
11310
11311/// Map a rusqlite value with no schema column to consult (arbitrary query
11312/// output): integers/reals/text pass through by stored affinity, blobs ride
11313/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
11314/// schema column type to recover booleans/floats/json.
11315fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
11316    use rusqlite::types::ValueRef;
11317    match value {
11318        ValueRef::Null => Value::Null,
11319        ValueRef::Integer(i) => {
11320            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
11321            // ordinary UI-sized integers ergonomic and envelope only values
11322            // beyond JavaScript's safe range.
11323            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
11324            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
11325                Value::from(i)
11326            } else {
11327                let mut map = Map::new();
11328                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
11329                Value::Object(map)
11330            }
11331        }
11332        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11333        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11334        ValueRef::Blob(b) => {
11335            let mut map = Map::new();
11336            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11337            Value::Object(map)
11338        }
11339    }
11340}
11341
11342/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
11343fn query_connection(
11344    conn: &Connection,
11345    sql: &str,
11346    params: &[QueryValue],
11347) -> Result<Vec<QueryRow>, QueryReadFailure> {
11348    crate::query_guard::assert_read_only_query(sql)?;
11349    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
11350    let bound: Vec<SqlValue> = params
11351        .iter()
11352        .map(json_param_to_sql)
11353        .collect::<Result<_, _>>()?;
11354    let mut stmt = conn.prepare(&lowered_sql)?;
11355    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
11356    let bound_refs: Vec<&dyn rusqlite::ToSql> =
11357        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
11358    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
11359    let mut out = Vec::new();
11360    while let Some(row) = sql_rows.next()? {
11361        let mut record = Map::new();
11362        for (i, name) in column_names.iter().enumerate() {
11363            let value = row.get_ref(i)?;
11364            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
11365        }
11366        out.push(record);
11367    }
11368    Ok(out)
11369}
11370
11371fn persisted_window_state(
11372    conn: &Connection,
11373    base: &WindowBase,
11374) -> Result<WindowState, QueryReadFailure> {
11375    let mut stmt = conn.prepare(
11376        "SELECT windows.unit, subscriptions.state_json
11377           FROM _syncular_windows AS windows
11378           JOIN _syncular_subscriptions AS subscriptions
11379             ON subscriptions.id = windows.sub_id
11380          WHERE windows.base = ?1
11381          ORDER BY windows.unit ASC",
11382    )?;
11383    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
11384        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11385    })?;
11386    let mut units = Vec::new();
11387    let mut pending = Vec::new();
11388    for row in rows {
11389        let (unit, raw) = row?;
11390        let state: Value = serde_json::from_str(&raw)
11391            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
11392        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
11393            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
11394            || state
11395                .get("bootstrapState")
11396                .is_some_and(|value| !value.is_null());
11397        if is_pending {
11398            pending.push(unit.clone());
11399        }
11400        units.push(unit);
11401    }
11402    Ok(WindowState { units, pending })
11403}
11404
11405/// One read-only statement in a §7.5 snapshot read.
11406#[derive(Debug, Clone, Copy)]
11407pub struct SnapshotStatement<'a> {
11408    pub sql: &'a str,
11409    pub params: &'a [Value],
11410}
11411
11412/// A §7.5 atomic read request: read-only statements, window coverage,
11413/// subscription catch-up states, and commit deliveries, all resolved from one
11414/// SQLite read transaction and one revision.
11415#[derive(Debug, Clone, Default)]
11416pub struct SnapshotReadRequest<'a> {
11417    pub statements: Vec<SnapshotStatement<'a>>,
11418    pub coverage: &'a [WindowCoverage],
11419    pub subscriptions: Vec<String>,
11420    pub commit_ids: Vec<String>,
11421}
11422
11423#[cfg(test)]
11424thread_local! {
11425    /// Test-only rendezvous that runs between the statements of one snapshot
11426    /// read. A test uses it to commit on another connection mid-read and prove
11427    /// the read transaction's snapshot and revision stay stable.
11428    static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
11429        std::cell::RefCell::new(None);
11430}
11431
11432#[cfg(test)]
11433fn run_snapshot_read_hook() {
11434    SNAPSHOT_READ_HOOK.with(|slot| {
11435        if let Some(hook) = slot.borrow_mut().take() {
11436            hook();
11437        }
11438    });
11439}
11440
11441/// §7.5: resolve a request over one SQLite read transaction. The revision,
11442/// every statement's rows, coverage, catch-up states, and deliveries are
11443/// captured under one `SAVEPOINT`; any statement's failure rolls the whole
11444/// read back and releases the connection.
11445fn snapshot_read_connection(
11446    conn: &Connection,
11447    request: &SnapshotReadRequest<'_>,
11448) -> Result<SnapshotRead, QueryReadFailure> {
11449    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
11450    let corrupt = |message: &str| QueryReadFailure {
11451        code: Some("sync.local_corrupt"),
11452        sqlite_code: None,
11453        sqlite_message: None,
11454        rollback_failure: None,
11455        message: message.to_owned(),
11456    };
11457    let stored_failure = |error: rusqlite::Error, message: &str| match error {
11458        rusqlite::Error::Utf8Error(..)
11459        | rusqlite::Error::InvalidColumnType(..)
11460        | rusqlite::Error::FromSqlConversionFailure(..)
11461        | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
11462        _ => QueryReadFailure::from(error),
11463    };
11464    let result: Result<SnapshotRead, QueryReadFailure> = (|| {
11465        // The durable revision as a canonical decimal `u64`; a missing marker
11466        // is the legacy zero, and any other non-canonical value is corrupt.
11467        let revision: Option<String> = conn
11468            .query_row(
11469                "SELECT value FROM _syncular_meta WHERE key = ?1",
11470                rusqlite::params![LOCAL_REVISION_KEY],
11471                |row| row.get(0),
11472            )
11473            .optional()
11474            .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
11475        let revision = match revision {
11476            None => "0".to_owned(),
11477            Some(value)
11478                if value == "0"
11479                    || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11480                        && value.bytes().all(|b| b.is_ascii_digit())
11481                        && value.parse::<u64>().is_ok()) =>
11482            {
11483                value
11484            }
11485            Some(_) => return Err(corrupt("persisted local revision is invalid")),
11486        };
11487        let mut queries = Vec::with_capacity(request.statements.len());
11488        for statement in &request.statements {
11489            queries.push(query_connection(conn, statement.sql, statement.params)?);
11490            #[cfg(test)]
11491            run_snapshot_read_hook();
11492        }
11493        let mut pending = Vec::new();
11494        let mut missing = Vec::new();
11495        for requested in request.coverage {
11496            let base_key = window_base_key(&requested.base);
11497            let state = persisted_window_state(conn, &requested.base)?;
11498            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
11499                let reference = WindowUnitRef {
11500                    base_key: base_key.clone(),
11501                    unit: unit.clone(),
11502                };
11503                if !state.units.iter().any(|held| held == &unit) {
11504                    missing.push(reference);
11505                } else if state.pending.iter().any(|held| held == &unit) {
11506                    pending.push(reference);
11507                }
11508            }
11509        }
11510        let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
11511        for id in &request.subscriptions {
11512            let row: Option<(String, String)> = conn
11513                .query_row(
11514                    "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
11515                    rusqlite::params![id],
11516                    |row| Ok((row.get(0)?, row.get(1)?)),
11517                )
11518                .optional()
11519                .map_err(|error| {
11520                    stored_failure(error, "persisted subscription state is invalid")
11521                })?;
11522            let Some((table, raw)) = row else {
11523                subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
11524                continue;
11525            };
11526            // Every persisted field is required to be well typed; a corrupt
11527            // record fails typed instead of inventing a state.
11528            let state: Value = serde_json::from_str(&raw)
11529                .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11530            let status = match state.get("status").and_then(Value::as_str) {
11531                Some(value @ ("active" | "revoked" | "failed")) => value,
11532                _ => return Err(corrupt("persisted subscription state is invalid")),
11533            };
11534            let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
11535                return Err(corrupt("persisted subscription state is invalid"));
11536            };
11537            if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
11538                return Err(corrupt("persisted subscription state is invalid"));
11539            }
11540            let effective_scopes = state
11541                .get("effectiveScopes")
11542                .filter(|value| !value.is_null());
11543            if let Some(scopes) = effective_scopes {
11544                json_to_scope_map(scopes)
11545                    .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11546            }
11547            let has_resume_token = match state.get("bootstrapState") {
11548                None | Some(Value::Null) => false,
11549                Some(Value::String(_)) => true,
11550                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11551            };
11552            // A present reasonCode must be a string; a non-string value is a
11553            // corrupt record, never silently dropped.
11554            let reason_code = match state.get("reasonCode") {
11555                None | Some(Value::Null) => None,
11556                Some(Value::String(value)) => Some(value.clone()),
11557                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11558            };
11559            let active = status == "active";
11560            subscriptions.push(SubscriptionCatchup::Known {
11561                id: id.clone(),
11562                table,
11563                status: status.to_owned(),
11564                cursor,
11565                has_resume_token,
11566                bootstrap_complete: active && cursor >= 0 && !has_resume_token,
11567                known_pending_pages: active && (has_resume_token || cursor < 0),
11568                effective_scopes: effective_scopes.cloned(),
11569                reason_code,
11570            });
11571        }
11572        let mut deliveries = Vec::with_capacity(request.commit_ids.len());
11573        for id in &request.commit_ids {
11574            let pending: bool = conn.query_row(
11575                "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
11576                rusqlite::params![id],
11577                |row| row.get(0),
11578            )?;
11579            if pending {
11580                deliveries.push(CommitDelivery::Pending {
11581                    client_commit_id: id.clone(),
11582                });
11583                continue;
11584            }
11585            let row = conn
11586                .query_row(
11587                    "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
11588                            resolution, resolved_at_ms, replacement_client_commit_id
11589                       FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
11590                    rusqlite::params![id],
11591                    StoredCommitOutcomeRow::from_row,
11592                )
11593                .optional()
11594                .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
11595            deliveries.push(match row {
11596                Some(row) => CommitDelivery::Known {
11597                    client_commit_id: id.clone(),
11598                    outcome: SyncClient::outcome_from_row(row)
11599                        .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
11600                },
11601                None => CommitDelivery::Unknown {
11602                    client_commit_id: id.clone(),
11603                },
11604            });
11605        }
11606        Ok(SnapshotRead {
11607            revision,
11608            queries,
11609            coverage: CoverageSnapshot {
11610                complete: pending.is_empty() && missing.is_empty(),
11611                pending,
11612                missing,
11613            },
11614            subscriptions,
11615            deliveries,
11616        })
11617    })();
11618    match result {
11619        Ok(read) => {
11620            conn.execute_batch("RELEASE syncular_snapshot_read")?;
11621            Ok(read)
11622        }
11623        Err(mut error) => {
11624            if let Err(rollback) = conn
11625                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
11626            {
11627                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
11628            }
11629            Err(error)
11630        }
11631    }
11632}
11633
11634fn snapshot_connection(
11635    conn: &Connection,
11636    sql: &str,
11637    params: &[Value],
11638    coverage: &[WindowCoverage],
11639) -> Result<QuerySnapshot, QueryReadFailure> {
11640    let read = snapshot_read_connection(
11641        conn,
11642        &SnapshotReadRequest {
11643            statements: vec![SnapshotStatement { sql, params }],
11644            coverage,
11645            subscriptions: Vec::new(),
11646            commit_ids: Vec::new(),
11647        },
11648    )?;
11649    Ok(QuerySnapshot {
11650        revision: read.revision,
11651        rows: read
11652            .queries
11653            .into_iter()
11654            .next()
11655            .expect("single-query snapshot returns one result"),
11656        coverage: read.coverage,
11657    })
11658}
11659
11660/// A long-lived read-only SQLite sidecar for latency-critical native views.
11661/// Network rounds stay serialized on the mutable core owner, while atomic
11662/// query snapshots use this independent connection and therefore never queue
11663/// behind HTTP/WebSocket latency.
11664pub struct FileQuerySnapshotReader {
11665    path: String,
11666    conn: Option<Connection>,
11667}
11668
11669impl FileQuerySnapshotReader {
11670    #[must_use]
11671    pub fn new(path: impl Into<String>) -> Self {
11672        Self {
11673            path: path.into(),
11674            conn: None,
11675        }
11676    }
11677
11678    fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
11679        if self.conn.is_none() {
11680            let conn = Connection::open_with_flags(
11681                &self.path,
11682                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
11683            )
11684            .map_err(QueryReadFailure::from)?;
11685            conn.busy_timeout(std::time::Duration::from_millis(250))
11686                .map_err(QueryReadFailure::from)?;
11687            self.conn = Some(conn);
11688        }
11689        self.conn
11690            .as_ref()
11691            .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
11692    }
11693
11694    /// One atomic read over the shared primitive: every statement, coverage,
11695    /// catch-up, and delivery under one `SAVEPOINT` and one revision.
11696    pub fn snapshot_read(
11697        &mut self,
11698        request: &SnapshotReadRequest<'_>,
11699    ) -> Result<SnapshotRead, QueryReadFailure> {
11700        snapshot_read_connection(self.connection()?, request)
11701    }
11702
11703    /// The current durable local revision. A host can skip a read when it is
11704    /// unchanged; a corrupt or unreadable marker fails typed.
11705    pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
11706        Ok(self
11707            .snapshot_read(&SnapshotReadRequest::default())?
11708            .revision)
11709    }
11710
11711    /// One subscription's persisted catch-up state (see the §7.5 contract).
11712    pub fn subscription_catchup(
11713        &mut self,
11714        id: &str,
11715    ) -> Result<SubscriptionCatchup, QueryReadFailure> {
11716        let read = self.snapshot_read(&SnapshotReadRequest {
11717            subscriptions: vec![id.to_owned()],
11718            ..Default::default()
11719        })?;
11720        Ok(read
11721            .subscriptions
11722            .into_iter()
11723            .next()
11724            .expect("one requested catch-up"))
11725    }
11726
11727    /// One client commit's delivery status: pending, its retained outcome, or
11728    /// an explicit unknown (never held, or pruned by retention).
11729    pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
11730        let read = self.snapshot_read(&SnapshotReadRequest {
11731            commit_ids: vec![id.to_owned()],
11732            ..Default::default()
11733        })?;
11734        Ok(read
11735            .deliveries
11736            .into_iter()
11737            .next()
11738            .expect("one requested delivery"))
11739    }
11740
11741    /// Hosts report an owned failure, and the first success after it, to the
11742    /// owning core through [`SyncClient::record_query_read`] (§7.6).
11743    pub fn query_snapshot(
11744        &mut self,
11745        sql: &str,
11746        params: &[Value],
11747        coverage: &[WindowCoverage],
11748    ) -> Result<QuerySnapshot, QueryReadFailure> {
11749        snapshot_connection(self.connection()?, sql, params, coverage)
11750    }
11751}
11752
11753fn validate_authority_reads(
11754    schema: &ClientSchema,
11755    declarations: &[crate::api::AuthorityReadDeclaration],
11756) -> Result<(), String> {
11757    let mut seen = BTreeSet::new();
11758    for read in declarations {
11759        let table = schema
11760            .table(&read.table)
11761            .ok_or("client.authority_read_forbidden: authority table is not declared")?;
11762        let plain = |name: &str| {
11763            table.columns.iter().enumerate().any(|(i, c)| {
11764                c.name == name
11765                    && name != "authority_version"
11766                    && !name.starts_with("_sync")
11767                    && !table.encrypted_columns.iter().any(|e| e.index == i)
11768                    && matches!(
11769                        c.ty,
11770                        ssp2::segment::ColumnType::String
11771                            | ssp2::segment::ColumnType::Integer
11772                            | ssp2::segment::ColumnType::Float
11773                            | ssp2::segment::ColumnType::Boolean
11774                            | ssp2::segment::ColumnType::Json
11775                    )
11776            })
11777        };
11778        if !seen.insert(&read.table)
11779            || read.columns.is_empty()
11780            || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
11781            || !read.columns.contains(&table.primary_key)
11782            || !read.columns.iter().all(|c| plain(c))
11783            || read.scopes.is_empty()
11784            || !read.scopes.iter().all(|(v, values)| {
11785                table
11786                    .scope_column(v)
11787                    .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
11788                    && !values.is_empty()
11789                    && values.iter().all(|v| !v.is_empty() && v != "*")
11790            })
11791        {
11792            return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
11793        }
11794    }
11795    Ok(())
11796}
11797
11798// §7.4.2: every open and recreation checks before any persistent writes.
11799/// A marker read that hit a recognized SQLite storage failure keeps that
11800/// classification (BUSY/LOCKED stay retryable); only an unclassified read
11801/// failure is the invalid-marker `sync.local_corrupt`.
11802fn schema_marker_read_failure(error: rusqlite::Error) -> String {
11803    let failure = QueryReadFailure::from(error);
11804    if failure.code.is_some() {
11805        failure.to_string()
11806    } else {
11807        "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
11808    }
11809}
11810
11811/// §7.1: reject an invalid configured client limit before any storage is
11812/// opened or written. Zero would wedge the push queue and an out-of-range
11813/// value would differ per target; the TS core enforces the same bounds.
11814fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
11815    if limits.outcome_retention_max_entries == Some(0) {
11816        return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
11817    }
11818    if [
11819        limits.max_push_commits_per_request,
11820        limits.max_push_operations_per_request,
11821        limits.max_push_request_bytes,
11822    ]
11823    .into_iter()
11824    .flatten()
11825    .any(|value| value == 0 || value > u32::MAX as usize)
11826    {
11827        return Err(
11828            "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
11829        );
11830    }
11831    Ok(())
11832}
11833
11834fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
11835    let corrupt =
11836        || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
11837    let table_type: Option<String> = conn
11838        .query_row(
11839            "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
11840            [],
11841            |row| row.get(0),
11842        )
11843        .optional()
11844        .map_err(schema_marker_read_failure)?;
11845    let Some(table_type) = table_type else {
11846        return Ok(None);
11847    };
11848    if table_type != "table" {
11849        return Err(corrupt());
11850    }
11851    // §7.4.2: a duplicated marker cannot be resolved. `query_row` would take
11852    // whichever row SQLite returns first, and an older one would drive a
11853    // destructive reset over a newer replica.
11854    let mut statement = conn
11855        .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
11856        .map_err(schema_marker_read_failure)?;
11857    let mut rows = statement
11858        .query([LOCAL_SCHEMA_VERSION_KEY])
11859        .map_err(schema_marker_read_failure)?;
11860    let value: Option<String> = rows
11861        .next()
11862        .map_err(schema_marker_read_failure)?
11863        .map(|row| row.get(0))
11864        .transpose()
11865        .map_err(schema_marker_read_failure)?;
11866    if rows.next().map_err(schema_marker_read_failure)?.is_some() {
11867        return Err(corrupt());
11868    }
11869    let Some(value) = value else {
11870        let has_descriptor = conn
11871            .query_row(
11872                "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
11873                [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
11874                |_| Ok(()),
11875            )
11876            .optional()
11877            .map_err(schema_marker_read_failure)?
11878            .is_some();
11879        if has_descriptor {
11880            return Err(corrupt());
11881        }
11882        return Ok(None);
11883    };
11884    if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11885        || !value.bytes().all(|b| b.is_ascii_digit())
11886    {
11887        return Err(corrupt());
11888    }
11889    let version = value.parse::<i32>().map_err(|_| corrupt())?;
11890    if version > requested {
11891        return Err(
11892            "client.schema_downgrade: persisted local schema is newer than the requested schema"
11893                .to_owned(),
11894        );
11895    }
11896    Ok(Some(version))
11897}
11898
11899/// A file replica on a browser worker must recover a hot journal before any
11900/// statement reads it. `wasm_vfs` replaces the installed OPFS VFS's reserved
11901/// lock callback, so install it before the connection opens its file.
11902#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
11903fn prepare_wasm_replica(path: &str) -> Result<(), String> {
11904    if path.is_empty() || path == ":memory:" {
11905        return Ok(());
11906    }
11907    crate::wasm_vfs::disable_foreign_lock_check()
11908}
11909
11910#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
11911fn prepare_wasm_replica(_path: &str) -> Result<(), String> {
11912    Ok(())
11913}
11914
11915impl SyncClient {
11916    pub fn new_with_identity(
11917        client_id: Option<String>,
11918        schema_json: &Value,
11919        limits: ClientLimits,
11920    ) -> Result<Self, String> {
11921        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11922        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11923        Self::with_connection(resolved, schema_json, limits, conn)
11924    }
11925
11926    pub fn new(
11927        client_id: String,
11928        schema_json: &Value,
11929        limits: ClientLimits,
11930    ) -> Result<Self, String> {
11931        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11932        Self::with_connection(client_id, schema_json, limits, conn)
11933    }
11934
11935    /// Build a client backed by an on-disk SQLite database at `path` — the
11936    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
11937    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
11938    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
11939    /// of the command router's dependency set (the router only holds a path).
11940    pub fn open_path(
11941        client_id: String,
11942        schema_json: &Value,
11943        limits: ClientLimits,
11944        path: &str,
11945    ) -> Result<Self, String> {
11946        validate_client_limits(&limits)?;
11947        parse_schema_json(schema_json)?;
11948        prepare_wasm_replica(path)?;
11949        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11950        Self::with_connection(client_id, schema_json, limits, conn)
11951    }
11952
11953    /// Open a file replica and resolve its client id. Native targets require
11954    /// WAL for the independent snapshot reader; `wasm32-unknown-unknown`
11955    /// accepts the sqlite-wasm worker journal instead.
11956    pub fn open_path_with_identity(
11957        client_id: Option<String>,
11958        schema_json: &Value,
11959        limits: ClientLimits,
11960        path: &str,
11961    ) -> Result<Self, String> {
11962        Self::open_path_with_identity_journal(
11963            client_id,
11964            schema_json,
11965            limits,
11966            path,
11967            !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
11968        )
11969    }
11970
11971    fn open_path_with_identity_journal(
11972        client_id: Option<String>,
11973        schema_json: &Value,
11974        limits: ClientLimits,
11975        path: &str,
11976        require_wal: bool,
11977    ) -> Result<Self, String> {
11978        validate_client_limits(&limits)?;
11979        // §7.4.2: an invalid requested version is refused before the replica is
11980        // even created, so a rejected constructor leaves no file behind.
11981        parse_schema_json(schema_json)?;
11982        prepare_wasm_replica(path)?;
11983        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11984        // A file-backed native client uses an independent read connection for
11985        // latency-critical snapshots. WAL is SQLite's intended reader/writer
11986        // concurrency mode: a view read never holds a rollback-journal lock
11987        // that delays the client's next commit. The busy timeout must precede
11988        // this replica's own startup transaction; WAL is applied only after
11989        // startup succeeded, because a refused open must leave the replica's
11990        // journal mode and contents untouched (§7.4.2).
11991        conn.busy_timeout(std::time::Duration::from_millis(250))
11992            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
11993        // Durability is the caller's contract here, not the VFS default: a
11994        // rollback-journal commit needs FULL before it writes the database
11995        // pages, or a killed worker can leave a half-written image behind.
11996        // WAL below inherits the same setting.
11997        conn.pragma_update(None, "synchronous", "FULL")
11998            .map_err(|error| format!("configure db {path:?} synchronous: {error}"))?;
11999        let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
12000        // A browser worker's sqlite-wasm VFS has no WAL mode, and the wasm
12001        // host has no independent snapshot reader. The WAL requirement exists
12002        // for native file replicas, where it is the reader/writer boundary.
12003        if require_wal {
12004            let journal_mode: String = client
12005                .conn
12006                .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
12007                .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12008            if journal_mode != "wal" {
12009                return Err(
12010                    "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
12011                );
12012            }
12013        }
12014        Ok(client)
12015    }
12016
12017    /// Delete a file replica at `path`, including its `-journal`, `-wal`, and
12018    /// `-shm` sidecars, through the VFS that owns it. Use this after
12019    /// `client.storage_corrupt`: an OPFS SAH pool stores those files under
12020    /// opaque names, so deleting the pool directory behind SQLite bypasses the
12021    /// pool's slot accounting.
12022    ///
12023    /// Close the client and every [`FileQuerySnapshotReader`] on `path` first;
12024    /// a VFS cannot delete a file another handle still owns. The next open
12025    /// creates a fresh replica (new client id) and re-bootstraps from the
12026    /// server. A memory replica has nothing to delete and fails with
12027    /// `sync.invalid_request`.
12028    pub fn reset_replica(path: &str) -> Result<(), String> {
12029        if path.is_empty() || path == ":memory:" {
12030            return Err("sync.invalid_request: a memory replica has nothing to reset".to_owned());
12031        }
12032        // SAFETY: a null VFS name asks SQLite for its process-wide default VFS,
12033        // the same one `Connection::open(path)` above selects. `xDelete` is
12034        // that VFS's own function pointer, and `c_name` outlives the call.
12035        let vfs = unsafe { rusqlite::ffi::sqlite3_vfs_find(std::ptr::null()) };
12036        if vfs.is_null() {
12037            return Err("client.storage_io: no SQLite VFS is registered".to_owned());
12038        }
12039        let delete = unsafe { (*vfs).xDelete }
12040            .ok_or_else(|| "client.storage_io: the SQLite VFS cannot delete files".to_owned())?;
12041        for suffix in ["-wal", "-shm", "-journal", ""] {
12042            let file_name = format!("{path}{suffix}");
12043            let c_name = std::ffi::CString::new(file_name.as_str()).map_err(|_| {
12044                format!("sync.invalid_request: replica path {file_name:?} contains a NUL byte")
12045            })?;
12046            // SAFETY: `vfs` and its `xDelete` pointer come from the live
12047            // SQLite library; `c_name` is a valid NUL-terminated path.
12048            let code = unsafe { delete(vfs, c_name.as_ptr(), 1) };
12049            if code != rusqlite::ffi::SQLITE_OK {
12050                return Err(format!(
12051                    "client.storage_io: deleting replica file {file_name:?} failed with SQLite code {code}"
12052                ));
12053            }
12054        }
12055        Ok(())
12056    }
12057
12058    #[must_use]
12059    pub fn client_id(&self) -> &str {
12060        &self.client_id
12061    }
12062
12063    /// Rotate the caller's client id (§1.5) and return the new one. Use it
12064    /// after a `sync.invalid_client_id` refusal (`recommendedAction:
12065    /// resetClientId`): the next round carries the new id. A live realtime
12066    /// socket is closed so the next connection registers with the new id.
12067    /// The replica and its outbox stay in place; wipe them yourself when the
12068    /// stored data belongs to the account that signed out.
12069    pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> String {
12070        self.disconnect_realtime(transport);
12071        let client_id = uuid::Uuid::new_v4().to_string();
12072        self.client_id.clone_from(&client_id);
12073        self.set_meta(CLIENT_ID_KEY, &client_id);
12074        self.round_failure = None;
12075        client_id
12076    }
12077
12078    /// Build a client over a caller-supplied rusqlite connection — the seam a
12079    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
12080    /// with an on-disk database (`Connection::open(path)`) rather than the
12081    /// default `:memory:`. The connection MUST be fresh (no pre-existing
12082    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
12083    /// the same file across process restarts reuses the persisted rows.
12084    pub fn with_connection(
12085        client_id: String,
12086        schema_json: &Value,
12087        limits: ClientLimits,
12088        conn: Connection,
12089    ) -> Result<Self, String> {
12090        Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
12091    }
12092
12093    /// [`Self::with_connection`] for `open_path_with_identity`: no requested id
12094    /// means a durable replica's persisted `clientId` wins over a fresh one.
12095    fn with_connection_identity(
12096        client_id: Option<String>,
12097        schema_json: &Value,
12098        limits: ClientLimits,
12099        conn: Connection,
12100    ) -> Result<Self, String> {
12101        validate_client_limits(&limits)?;
12102        let schema = parse_schema_json(schema_json)?;
12103        validate_authority_reads(&schema, &limits.authority_reads)?;
12104        // RFC 0005 D8: resolve the previous-version config before the opening
12105        // reset runs — the capture happens inside it. Bad bounds fail loud.
12106        let previous_version = match limits.previous_version_context {
12107            Some(config) => {
12108                config.validate()?;
12109                Some(config)
12110            }
12111            None => None,
12112        };
12113        let mut client = SyncClient {
12114            #[cfg(feature = "bench-internals")]
12115            benchmark_phases: Recorder::default(),
12116            conn,
12117            schema,
12118            client_id: client_id
12119                .clone()
12120                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
12121            limits,
12122            subs: Vec::new(),
12123            outbox: Vec::new(),
12124            failed_commits: Vec::new(),
12125            retain_failed_commits: false,
12126            conflicts: Vec::new(),
12127            rejections: Vec::new(),
12128            schema_floor: None,
12129            lease_state: None,
12130            stopped: false,
12131            upgrading: false,
12132            sync_needed: false,
12133            realtime_policy: RealtimePolicy::Optional,
12134            realtime_state: RealtimeState::Disconnected,
12135            realtime_reason_code: None,
12136            realtime_retry_delay_ms: None,
12137            presence: HashMap::new(),
12138            now_ms: None,
12139            encryption: crate::values::EncryptionConfig::default(),
12140            security_preflight: false,
12141            transport_enabled: true,
12142            previous_version,
12143            insert_sql: RefCell::new(HashMap::new()),
12144            overlay_dirty: OverlayDirty::clean(),
12145            storage_failure: RefCell::new(None),
12146            #[cfg(test)]
12147            overlay_rebuild_count: Cell::new(0),
12148            #[cfg(test)]
12149            acknowledged_replay_count: Cell::new(0),
12150            #[cfg(test)]
12151            outcome_prune_count: Cell::new(0),
12152            progress: ProgressObserver::default(),
12153            change_queue: VecDeque::new(),
12154            sync_intent_queue: VecDeque::new(),
12155            retry_delay_ms: 250,
12156            round_retry_delay_ms: None,
12157            round_failure: None,
12158            active_round: None,
12159            last_round: None,
12160            last_change: None,
12161            query_failures: Vec::new(),
12162            coalesced: None,
12163            coalesced_failure: None,
12164        };
12165        // The row write path leans on the prepared-statement cache (two
12166        // insert statements per synced table, plus the bookkeeping
12167        // statements); size it so a multi-table schema never thrashes.
12168        client
12169            .conn
12170            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
12171        // §7.4.2: one write transaction (SQLite `BEGIN IMMEDIATE`, a reserved
12172        // writer) covers the marker read, the identity check, and every
12173        // bookkeeping/schema write, so a concurrent open that upgraded the
12174        // replica cannot interleave between the guard and the writes, and a
12175        // refusal leaves the replica unchanged.
12176        client
12177            .conn
12178            .execute_batch("BEGIN IMMEDIATE")
12179            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12180        let startup = (|| -> Result<(), String> {
12181            // The marker guard runs inside the write transaction, so the version
12182            // it validates is the version every write below commits under.
12183            let marker = read_local_schema_version(&client.conn, client.schema.version)?;
12184            // New app indexes may reference columns that only exist after a
12185            // version-bump reset.
12186            client.create_bookkeeping_tables()?;
12187            match client.get_meta(CLIENT_ID_KEY) {
12188                Some(existing) => match client_id {
12189                    Some(requested) if existing != requested => {
12190                        return Err(format!(
12191                            "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
12192                        ))
12193                    }
12194                    _ => client.client_id = existing,
12195                },
12196                None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
12197            }
12198            client.restore_persisted_state()?;
12199            client.failed_commits = client.load_failed_commits()?;
12200            match marker {
12201                None => {
12202                    client.create_synced_tables()?;
12203                    client.persist_schema_version()?;
12204                    client.save_subscription_scope_schema();
12205                }
12206                Some(version) if version == client.schema.version => {
12207                    client.create_synced_tables()?;
12208                    // RFC 0005 D1: the same-version open backfills the descriptor
12209                    // for a database first opened by an unaware binary, so the
12210                    // NEXT bump can capture without needing a schema bump first.
12211                    set_local_schema_descriptor(&client.conn, &client.schema)
12212                        .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12213                    client.save_subscription_scope_schema();
12214                }
12215                Some(_) => client.run_schema_reset()?,
12216            }
12217            // RFC 0005 D9: the reset sweep cannot see a container whose capture
12218            // committed in its OWN file while the replica's savepoint never
12219            // released — a crash at that seam leaves a container beside a matching
12220            // OLD marker, and the next open at that version runs no reset. Discard
12221            // it before anything can read it. No-op when no container exists.
12222            client.reconcile_previous_version_at_boot()?;
12223            client.clear_satisfied_persisted_schema_floor();
12224            client.prune_unknown_subscriptions(false)?;
12225            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
12226            // the full startup/reset reconciliation boundary, inside this open
12227            // transaction and after the schema and identity guards, so a commit
12228            // an earlier version accepted is dropped with durable rejection
12229            // evidence before any replay. A table the current schema removed
12230            // keeps the §7.4.4 send-time classification, and an unresolved
12231            // encryption key stays with the send seam because hosts configure
12232            // keys after open.
12233            client.drop_unencodable_outbox(false, None)?;
12234            if marker == Some(client.schema.version)
12235                && (!client.outbox.is_empty()
12236                    || !client.failed_commits.is_empty()
12237                    || client.has_acknowledged_rows())
12238            {
12239                // Reconstruct the visible optimistic overlay from the durable
12240                // base plus outbox instead of trusting a process-interrupted
12241                // mirror.
12242                client.overlay_dirty.set(true);
12243                client.rebuild_overlay()?;
12244            }
12245            // Every persisted active subscription needs one catch-up pull on
12246            // open: realtime only covers changes after connection, while an
12247            // idempotent setWindow correctly creates no fresh command effect.
12248            // Pending outbox work has the same restart requirement. The core
12249            // owns this intent so native hosts never poll or require an
12250            // application-issued sync().
12251            client.enqueue_startup_sync_if_needed()?;
12252            Ok(())
12253        })();
12254        match startup {
12255            Ok(()) => match client.conn.execute_batch("COMMIT") {
12256                Ok(()) => Ok(client),
12257                Err(error) => {
12258                    let failure = Self::sqlite_failure(&client.storage_failure, error);
12259                    if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12260                        // Keep a failed rollback's typed storage classification.
12261                        let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12262                    }
12263                    Err(failure)
12264                }
12265            },
12266            Err(error) => {
12267                if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12268                    // Keep a failed rollback's typed storage classification.
12269                    let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12270                }
12271                Err(error)
12272            }
12273        }
12274    }
12275
12276    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
12277    /// against this instead of system time (conformance virtual clock).
12278    pub fn set_now_ms(&mut self, now_ms: i64) {
12279        self.now_ms = Some(now_ms);
12280    }
12281
12282    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
12283    /// The command router parses these from the `create` command's
12284    /// `encryption` config (keys as `{$bytes: hex}`).
12285    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
12286        self.encryption = encryption;
12287    }
12288
12289    #[must_use]
12290    pub fn security_lifecycle(&self) -> &'static str {
12291        if self.security_preflight {
12292            "preflight"
12293        } else {
12294            "active"
12295        }
12296    }
12297
12298    #[must_use]
12299    pub fn security_preflight(&self) -> bool {
12300        self.security_preflight
12301    }
12302
12303    /// Host-owned, non-persisted network gate. Local operations remain available.
12304    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
12305        if self.transport_enabled != enabled {
12306            self.transport_enabled = enabled;
12307            self.sync_intent_queue.clear();
12308            self.sync_intent_queue
12309                .push_back(if enabled && !self.security_preflight {
12310                    SyncIntent::Interactive
12311                } else {
12312                    SyncIntent::None
12313                });
12314        }
12315        // A captured reply keeps its normal apply/revocation semantics.
12316        if !enabled && self.active_round.is_none() {
12317            self.disconnect_realtime(transport);
12318        }
12319    }
12320
12321    pub fn transport_enabled(&self) -> bool {
12322        self.transport_enabled
12323    }
12324
12325    /// Quarantine this replica: enter the fail-closed gate, release all
12326    /// core-owned key material, and record the gate in the database so
12327    /// reopening re-enters preflight until `activate_security` clears it.
12328    ///
12329    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
12330    /// which must NOT leave that durable mark.
12331    pub fn begin_security_preflight(&mut self) {
12332        self.seal_security_on_teardown();
12333        // Persist the quarantine so it survives handle teardown and restart:
12334        // reopening this replica re-enters preflight until activation clears it.
12335        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
12336    }
12337
12338    /// Release core-owned key material as a host tears the client down,
12339    /// WITHOUT recording a quarantine.
12340    ///
12341    /// Shutting an activated client down is not a quarantine event. Persisting
12342    /// the gate here marks every cleanly closed replica as pending, and the
12343    /// next plain `create` is then refused permanently: the reopen path
12344    /// restores the flag from the marker and the create guard rejects it. The
12345    /// in-memory flag still closes the gate for anything still holding this
12346    /// instance, which is all a teardown barrier needs.
12347    pub fn seal_security_on_teardown(&mut self) {
12348        self.cancel_sync_round();
12349        self.security_preflight = true;
12350        self.encryption = crate::values::EncryptionConfig::default();
12351        self.sync_intent_queue.clear();
12352    }
12353
12354    /// Install the post-authentication keyring and release the host loop.
12355    pub fn activate_security(
12356        &mut self,
12357        encryption: crate::values::EncryptionConfig,
12358    ) -> Result<(), String> {
12359        if !self.security_preflight {
12360            return Err(
12361                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
12362            );
12363        }
12364        self.enqueue_startup_sync_if_needed()?;
12365        self.encryption = encryption;
12366        self.security_preflight = false;
12367        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
12368        Ok(())
12369    }
12370
12371    fn clock_now_ms(&self) -> i64 {
12372        self.now_ms.unwrap_or_else(crate::wall_clock_ms)
12373    }
12374
12375    fn create_bookkeeping_tables(&self) -> Result<(), String> {
12376        // Durable client bookkeeping (outbox + subscription + meta).
12377        self.conn
12378            .execute_batch(
12379                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
12380                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
12381                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
12382                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
12383                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
12384                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
12385                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
12386                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
12387                   PRIMARY KEY(commit_id, idx));
12388                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
12389                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
12390                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
12391                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
12392                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
12393                   client_commit_id TEXT NOT NULL UNIQUE,
12394                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
12395                   recorded_at_ms INTEGER NOT NULL,
12396                   results_json TEXT NOT NULL,
12397                   operations_json TEXT,
12398                   resolution TEXT NOT NULL DEFAULT 'active'
12399                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
12400                   resolved_at_ms INTEGER,
12401                   replacement_client_commit_id TEXT);
12402                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
12403                   ON _syncular_commit_outcomes(resolution, seq);
12404                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
12405                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
12406                 CREATE TABLE IF NOT EXISTS _syncular_meta (
12407                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
12408                 CREATE TABLE IF NOT EXISTS _syncular_windows (
12409                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
12410                   PRIMARY KEY (base, unit));
12411                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
12412                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
12413                   effective_scopes TEXT NOT NULL);
12414                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
12415                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
12416                   PRIMARY KEY(commit_id, blob_id));
12417                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
12418                   ON _syncular_blob_commit_refs(blob_id);",
12419            )
12420            .map_err(|e| e.to_string())?;
12421        // Migrate an outcome journal created before failed aggregate
12422        // envelopes were retained. Historical rows intentionally stay NULL.
12423        let _ = self
12424            .conn
12425            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
12426        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
12427            self.set_meta(LOCAL_REVISION_KEY, "0");
12428        }
12429        // §5.9.7 blob cache. Mutable upload state stays off the body row so
12430        // completing a large upload does not rewrite its BLOB record.
12431        if self.schema_has_blobs() {
12432            let exists = self
12433                .conn
12434                .query_row(
12435                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
12436                    [],
12437                    |row| row.get::<_, bool>(0),
12438                )
12439                .map_err(|e| e.to_string())?;
12440            if exists {
12441                let mut statement = self
12442                    .conn
12443                    .prepare("PRAGMA table_info(_syncular_blobs)")
12444                    .map_err(|e| e.to_string())?;
12445                let mut columns = statement
12446                    .query_map([], |row| row.get::<_, String>(1))
12447                    .map_err(|e| e.to_string())?
12448                    .collect::<Result<Vec<_>, _>>()
12449                    .map_err(|e| e.to_string())?;
12450                columns.sort();
12451                let mut expected = vec![
12452                    "blob_id",
12453                    "byte_length",
12454                    "bytes",
12455                    "created_at_ms",
12456                    "media_type",
12457                ];
12458                expected.sort_unstable();
12459                if columns != expected {
12460                    return Err(
12461                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
12462                    );
12463                }
12464            } else {
12465                self.conn
12466                    .execute_batch(
12467                        "CREATE TABLE _syncular_blobs(
12468                           blob_id TEXT PRIMARY KEY,
12469                           bytes BLOB NOT NULL,
12470                           byte_length INTEGER NOT NULL,
12471                           media_type TEXT,
12472                           created_at_ms INTEGER NOT NULL);",
12473                    )
12474                    .map_err(|e| e.to_string())?;
12475            }
12476            self.conn
12477                .execute_batch(
12478                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
12479                       blob_id TEXT PRIMARY KEY,
12480                       media_type TEXT,
12481                       created_at_ms INTEGER NOT NULL);",
12482                )
12483                .map_err(|e| e.to_string())?;
12484        }
12485        Ok(())
12486    }
12487
12488    /// True iff any synced table declares a `blob_ref` column (§5.9).
12489    fn schema_has_blobs(&self) -> bool {
12490        self.schema
12491            .tables
12492            .iter()
12493            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
12494    }
12495
12496    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
12497
12498    fn get_meta(&self, key: &str) -> Option<String> {
12499        meta_get(&self.conn, key)
12500    }
12501
12502    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
12503        self.conn
12504            .query_row(
12505                "SELECT value FROM _syncular_meta WHERE key = ?1",
12506                rusqlite::params![key],
12507                |row| row.get::<_, String>(0),
12508            )
12509            .optional()
12510            .map_err(|_| {
12511                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
12512            })
12513    }
12514
12515    // The caller holds the startup/reset transaction. Either both schema
12516    // records persist or the enclosing operation fails and rolls back.
12517    fn persist_schema_version(&self) -> Result<(), String> {
12518        self.conn
12519            .execute(
12520                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12521                rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
12522            )
12523            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12524        set_local_schema_descriptor(&self.conn, &self.schema)
12525            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12526    }
12527
12528    pub(crate) fn set_meta(&self, key: &str, value: &str) {
12529        meta_set(&self.conn, key, value);
12530    }
12531
12532    fn delete_meta(&self, key: &str) {
12533        meta_delete(&self.conn, key);
12534    }
12535
12536    /// §7.1: the durable outbox in FIFO order, in schema-agnostic local form.
12537    fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
12538        let mut stmt = self
12539            .conn
12540            .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
12541            .map_err(|error| error.to_string())?;
12542        let rows = stmt
12543            .query_map([], |row| {
12544                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
12545            })
12546            .map_err(|error| error.to_string())?;
12547        let mut commits = Vec::new();
12548        for row in rows {
12549            let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
12550            let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
12551                format!("invalid persisted outbox {client_commit_id:?}: {error}")
12552            })?;
12553            let mut ops = Vec::with_capacity(entries.len());
12554            for entry in entries {
12555                let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
12556                ops.push(OutboxOp {
12557                    upsert: op == "upsert",
12558                    table: entry
12559                        .get("table")
12560                        .and_then(Value::as_str)
12561                        .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
12562                        .to_owned(),
12563                    row_id: entry
12564                        .get("rowId")
12565                        .and_then(Value::as_str)
12566                        .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
12567                        .to_owned(),
12568                    base_version: entry.get("baseVersion").and_then(Value::as_i64),
12569                    // A legacy entry's `changedFields` key is ignored:
12570                    // the values map is the presence set (§6.1).
12571                    values: entry.get("values").and_then(Value::as_object).cloned(),
12572                    insert: entry.get("insert") == Some(&Value::Bool(true)),
12573                });
12574            }
12575            commits.push(OutboxCommit {
12576                client_commit_id,
12577                ops,
12578            });
12579        }
12580        Ok(commits)
12581    }
12582
12583    fn restore_persisted_state(&mut self) -> Result<(), String> {
12584        self.subs = {
12585            let mut stmt = self
12586                .conn
12587                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
12588                .map_err(|error| error.to_string())?;
12589            let rows = stmt
12590                .query_map([], |row| {
12591                    Ok((
12592                        row.get::<_, String>(0)?,
12593                        row.get::<_, String>(1)?,
12594                        row.get::<_, String>(2)?,
12595                    ))
12596                })
12597                .map_err(|error| error.to_string())?;
12598            let mut subscriptions = Vec::new();
12599            for row in rows {
12600                let (id, table, raw) = row.map_err(|error| error.to_string())?;
12601                let state: Value = serde_json::from_str(&raw)
12602                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
12603                let requested = json_to_scope_map(
12604                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
12605                )?;
12606                let effective = state
12607                    .get("effectiveScopes")
12608                    .filter(|value| !value.is_null())
12609                    .map(json_to_scope_map)
12610                    .transpose()?;
12611                subscriptions.push(Subscription {
12612                    id,
12613                    table,
12614                    requested,
12615                    params: state
12616                        .get("params")
12617                        .and_then(Value::as_str)
12618                        .map(str::to_owned),
12619                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
12620                    bootstrap_state: state
12621                        .get("bootstrapState")
12622                        .and_then(Value::as_str)
12623                        .map(str::to_owned),
12624                    state: SubState::parse(
12625                        state
12626                            .get("status")
12627                            .and_then(Value::as_str)
12628                            .unwrap_or("active"),
12629                    ),
12630                    reason_code: state
12631                        .get("reasonCode")
12632                        .and_then(Value::as_str)
12633                        .map(str::to_owned),
12634                    effective,
12635                    synced_once: state
12636                        .get("syncedOnce")
12637                        .and_then(Value::as_bool)
12638                        .unwrap_or(false),
12639                });
12640            }
12641            subscriptions
12642        };
12643
12644        self.outbox = self.load_outbox()?;
12645
12646        self.prune_commit_outcomes()?;
12647        let active = self.commit_outcomes(CommitOutcomeQuery {
12648            active_only: true,
12649            ..CommitOutcomeQuery::default()
12650        })?;
12651        self.conflicts = active
12652            .iter()
12653            .flat_map(|outcome| outcome.results.iter())
12654            .filter_map(|result| match result {
12655                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
12656                _ => None,
12657            })
12658            .collect();
12659        self.rejections = active
12660            .iter()
12661            .flat_map(|outcome| outcome.results.iter())
12662            .filter_map(|result| match result {
12663                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
12664                _ => None,
12665            })
12666            .collect();
12667
12668        self.lease_state = self
12669            .get_meta(LEASE_STATE_KEY)
12670            .map(|raw| serde_json::from_str(&raw))
12671            .transpose()
12672            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
12673        self.schema_floor = self
12674            .get_meta(SCHEMA_FLOOR_KEY)
12675            .map(|raw| serde_json::from_str(&raw))
12676            .transpose()
12677            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
12678        self.stopped = self.schema_floor.is_some();
12679        // A replica left in unactivated preflight reopens gated: the quarantine
12680        // decision persists with the data across handle teardown and restart.
12681        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
12682            self.security_preflight = true;
12683        }
12684        Ok(())
12685    }
12686
12687    fn save_subscription_scope_schema(&self) {
12688        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
12689            .schema
12690            .tables
12691            .iter()
12692            .map(|table| {
12693                (
12694                    table.name.clone(),
12695                    table
12696                        .scope_variables
12697                        .iter()
12698                        .map(|scope| {
12699                            (
12700                                scope.variable.clone(),
12701                                [scope.prefix.clone(), scope.column.clone()],
12702                            )
12703                        })
12704                        .collect(),
12705                )
12706            })
12707            .collect();
12708        self.set_meta(
12709            "subscriptionScopeSchema",
12710            &serde_json::to_string(&declarations).expect("scope schema serialization"),
12711        );
12712    }
12713
12714    /// Remove registrations with incompatible table/key/prefix/column meaning.
12715    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
12716        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
12717            self.get_meta("subscriptionScopeSchema")
12718                .map(|raw| serde_json::from_str(&raw))
12719                .transpose()
12720                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
12721        } else {
12722            None
12723        };
12724        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
12725            let Some(table) = self.schema.table(table_name) else {
12726                return false;
12727            };
12728            if requested
12729                .iter()
12730                .any(|(variable, _)| table.scope_column(variable).is_none())
12731            {
12732                return false;
12733            }
12734            if !bump {
12735                return true;
12736            }
12737            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
12738                return false;
12739            };
12740            let variables: BTreeSet<_> = if requested.is_empty() {
12741                old.keys()
12742                    .cloned()
12743                    .chain(
12744                        table
12745                            .scope_variables
12746                            .iter()
12747                            .map(|scope| scope.variable.clone()),
12748                    )
12749                    .collect()
12750            } else {
12751                requested
12752                    .iter()
12753                    .map(|(variable, _)| variable.clone())
12754                    .collect()
12755            };
12756            variables.iter().all(|variable| {
12757                let current = table
12758                    .scope_variables
12759                    .iter()
12760                    .find(|scope| &scope.variable == variable);
12761                match (old.get(variable), current) {
12762                    (Some(old), Some(current)) => {
12763                        old == &[current.prefix.clone(), current.column.clone()]
12764                    }
12765                    _ => false,
12766                }
12767            })
12768        };
12769        let mut stale_ids: BTreeSet<_> = self
12770            .subs
12771            .iter()
12772            .filter(|sub| !compatible(&sub.table, &sub.requested))
12773            .map(|sub| sub.id.clone())
12774            .collect();
12775        for (id, table, effective) in self.load_pending_evictions()? {
12776            if !compatible(&table, &effective) {
12777                stale_ids.insert(id);
12778            }
12779        }
12780        for id in &stale_ids {
12781            for sql in [
12782                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
12783                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
12784                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
12785            ] {
12786                self.conn
12787                    .execute(sql, rusqlite::params![id])
12788                    .map_err(|error| error.to_string())?;
12789            }
12790        }
12791        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
12792        Ok(())
12793    }
12794
12795    #[must_use]
12796    pub fn local_revision(&self) -> u64 {
12797        self.get_meta(LOCAL_REVISION_KEY)
12798            .and_then(|value| value.parse().ok())
12799            .unwrap_or(0)
12800    }
12801
12802    /// §7.5: resolve a snapshot read on the mutable owner connection through
12803    /// the same primitive the read-only sidecar uses, so the command surface
12804    /// and the file sidecar return one revision, rows, coverage, catch-up, and
12805    /// delivery shape. The structured [`QueryReadFailure`] is preserved; an
12806    /// `owner` records its failure (and its next success) in diagnostics.
12807    pub fn snapshot_read(
12808        &mut self,
12809        request: &SnapshotReadRequest<'_>,
12810        owner: Option<QueryOwner<'_>>,
12811    ) -> Result<SnapshotRead, QueryReadFailure> {
12812        let result = snapshot_read_connection(&self.conn, request);
12813        if let Some(owner) = owner {
12814            self.record_query_read(owner, result.as_ref().err());
12815        }
12816        result
12817    }
12818
12819    #[must_use]
12820    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
12821        SyncStatusSnapshot {
12822            current_schema_version: self.schema.version,
12823            outbox: self.outbox.len(),
12824            upgrading: self.upgrading,
12825            lease_state: self.lease_state.clone(),
12826            schema_floor: self.schema_floor.clone(),
12827            sync_needed: self.sync_needed,
12828            previous_version_context: self.previous_version_status(),
12829        }
12830    }
12831
12832    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
12833    /// rollback precondition), read without creating the file.
12834    fn previous_version_status(&self) -> PreviousVersionStatus {
12835        let Some(replica_path) = self.previous_version_replica_path() else {
12836            return PreviousVersionStatus {
12837                present: false,
12838                created_at_ms: None,
12839            };
12840        };
12841        match crate::previous_version::read_previous_version_record(&replica_path) {
12842            Ok(Some(record)) => PreviousVersionStatus {
12843                present: true,
12844                created_at_ms: Some(record.created_at_ms),
12845            },
12846            _ => PreviousVersionStatus {
12847                present: std::path::Path::new(
12848                    &crate::previous_version::previous_version_container_path(&replica_path),
12849                )
12850                .exists(),
12851                created_at_ms: None,
12852            },
12853        }
12854    }
12855
12856    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
12857    /// is always `previousVersion`; the container never makes
12858    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
12859    /// revocation, coverage completion and the TTL DISCARD the container and
12860    /// close the read with their own reason.
12861    pub fn previous_version_snapshot(
12862        &self,
12863        spec: &PreviousVersionReadSpec,
12864    ) -> Result<PreviousVersionSnapshot, String> {
12865        let now_ms = self.clock_now_ms();
12866        crate::previous_version::previous_version_snapshot(
12867            &self.conn,
12868            self.previous_version_replica_path().as_deref(),
12869            self.previous_version.as_ref(),
12870            PreviousVersionLifecycle {
12871                lease_inactive: self.previous_version_lease_inactive(now_ms),
12872                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
12873                coverage_complete: self.previous_version_coverage_complete(),
12874            },
12875            now_ms,
12876            self.schema.version,
12877            spec,
12878        )
12879    }
12880
12881    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
12882    #[must_use]
12883    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
12884        crate::previous_version::stored_previous_version_audit(&self.conn)
12885    }
12886
12887    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
12888    /// and both metadata records, and report whether anything was present.
12889    /// Idempotent: absence is a successful no-op.
12890    pub fn previous_version_discard(
12891        &self,
12892    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
12893        self.drop_previous_version()
12894    }
12895
12896    /// The module's discard has no failure path (it removes the file even when
12897    /// the container cannot be opened), so the only error a caller could ever
12898    /// see is a bug; report absence rather than claiming a discard happened.
12899    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
12900        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
12901            present: false,
12902            discarded: false,
12903        };
12904        let Some(replica_path) = self.previous_version_replica_path() else {
12905            return absent;
12906        };
12907        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
12908            .unwrap_or(absent)
12909    }
12910
12911    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
12912    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
12913        let Some(lease) = &self.lease_state else {
12914            return false;
12915        };
12916        if lease.error_code.is_some() {
12917            return true;
12918        }
12919        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
12920    }
12921
12922    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
12923    /// has `cursor >= 0` and no resume token. An empty active set is not
12924    /// completion (mirrors the TS predicate exactly).
12925    fn previous_version_coverage_complete(&self) -> bool {
12926        let active = self
12927            .subs
12928            .iter()
12929            .filter(|sub| sub.state == SubState::Active)
12930            .collect::<Vec<_>>();
12931        if active.is_empty() {
12932            return false;
12933        }
12934        active
12935            .iter()
12936            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
12937    }
12938
12939    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
12940    /// apply. A container that no longer has a reason to exist is discarded so
12941    /// the rows do not outlive the window they were kept for.
12942    fn previous_version_lifetime_check(&self) {
12943        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
12944            return;
12945        };
12946        let now_ms = self.clock_now_ms();
12947        if self.previous_version_lease_inactive(now_ms) {
12948            let _ = self.drop_previous_version();
12949            return;
12950        }
12951        let Some(replica_path) = self.previous_version_replica_path() else {
12952            return;
12953        };
12954        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
12955        else {
12956            return;
12957        };
12958        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
12959            let _ = self.drop_previous_version();
12960            return;
12961        }
12962        if now_ms - record.created_at_ms > config.max_age_ms {
12963            let _ = self.drop_previous_version();
12964            return;
12965        }
12966        if self.previous_version_coverage_complete() {
12967            let _ = self.drop_previous_version();
12968        }
12969    }
12970
12971    pub fn diagnostics_snapshot(
12972        &self,
12973        request: &ClientDiagnosticsRequest,
12974    ) -> Result<ClientDiagnosticsSnapshot, String> {
12975        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12976            return Err(format!(
12977                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12978            ));
12979        }
12980        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
12981        for sub in &self.subs {
12982            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
12983            let complete =
12984                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
12985            let state = match sub.state {
12986                SubState::Revoked => "revoked",
12987                SubState::Failed => "failed",
12988                SubState::Active if reset => "reset",
12989                SubState::Active if complete => "complete",
12990                SubState::Active => "bootstrapping",
12991            };
12992            subscriptions.insert(
12993                sub.id.clone(),
12994                DiagnosticSubscription {
12995                    id: sub.id.clone(),
12996                    table: sub.table.clone(),
12997                    state: state.to_owned(),
12998                    complete,
12999                    cursor: Some(sub.cursor),
13000                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
13001                },
13002            );
13003        }
13004        for expected in &request.expected_subscriptions {
13005            if expected.id.is_empty() || expected.table.is_empty() {
13006                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
13007            }
13008            if subscriptions
13009                .get(&expected.id)
13010                .is_some_and(|registered| registered.table != expected.table)
13011            {
13012                subscriptions.insert(
13013                    expected.id.clone(),
13014                    DiagnosticSubscription {
13015                        id: expected.id.clone(),
13016                        table: expected.table.clone(),
13017                        state: "failed".to_owned(),
13018                        complete: false,
13019                        cursor: None,
13020                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
13021                    },
13022                );
13023            } else {
13024                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
13025                    DiagnosticSubscription {
13026                        id: expected.id.clone(),
13027                        table: expected.table.clone(),
13028                        state: "unregistered".to_owned(),
13029                        complete: false,
13030                        cursor: None,
13031                        reason_code: None,
13032                    }
13033                });
13034            }
13035        }
13036        let mut ordered_subscriptions = Vec::new();
13037        let mut included = BTreeSet::new();
13038        for expected in &request.expected_subscriptions {
13039            if included.insert(expected.id.clone()) {
13040                if let Some(subscription) = subscriptions.get(&expected.id) {
13041                    ordered_subscriptions.push(subscription.clone());
13042                }
13043            }
13044        }
13045        for (id, subscription) in &subscriptions {
13046            if included.insert(id.clone()) {
13047                ordered_subscriptions.push(subscription.clone());
13048            }
13049        }
13050        let subscriptions_truncated =
13051            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
13052        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
13053        let captured_at_ms = self.clock_now_ms();
13054        let lease = if let Some(error_code) = self
13055            .lease_state
13056            .as_ref()
13057            .and_then(|state| state.error_code.clone())
13058        {
13059            ClientDiagnosticsLease {
13060                state: "stopped".to_owned(),
13061                expires_at_ms: self
13062                    .lease_state
13063                    .as_ref()
13064                    .and_then(|state| state.expires_at_ms),
13065                error_code: Some(Self::diagnostic_code(&error_code)),
13066            }
13067        } else if let Some(expires_at_ms) = self
13068            .lease_state
13069            .as_ref()
13070            .and_then(|state| state.expires_at_ms)
13071        {
13072            ClientDiagnosticsLease {
13073                state: if expires_at_ms <= captured_at_ms {
13074                    "expired".to_owned()
13075                } else {
13076                    "active".to_owned()
13077                },
13078                expires_at_ms: Some(expires_at_ms),
13079                error_code: None,
13080            }
13081        } else {
13082            ClientDiagnosticsLease {
13083                state: "none".to_owned(),
13084                expires_at_ms: None,
13085                error_code: None,
13086            }
13087        };
13088        let connectivity = match self.last_round.as_ref() {
13089            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
13090            Some(round) if round.status == "succeeded" => "online",
13091            Some(round)
13092                if round.status == "failed"
13093                    && round
13094                        .error_code
13095                        .as_deref()
13096                        .is_some_and(Self::retryable_transport_code) =>
13097            {
13098                "offline"
13099            }
13100            _ => "unknown",
13101        };
13102        Ok(ClientDiagnosticsSnapshot {
13103            version: CLIENT_DIAGNOSTICS_VERSION,
13104            captured_at_ms,
13105            host: ClientDiagnosticsHost {
13106                kind: "direct".to_owned(),
13107                role: "single".to_owned(),
13108                connectivity: connectivity.to_owned(),
13109                realtime: self.realtime_state,
13110                realtime_policy: self.realtime_policy,
13111                realtime_reason_code: self.realtime_reason_code.clone(),
13112                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
13113            },
13114            security_lifecycle: self.security_lifecycle().to_owned(),
13115            schema: ClientDiagnosticsSchema {
13116                current_version: self.schema.version,
13117                upgrading: self.upgrading,
13118                required_version: self
13119                    .schema_floor
13120                    .as_ref()
13121                    .and_then(|floor| floor.required_schema_version),
13122                latest_version: self
13123                    .schema_floor
13124                    .as_ref()
13125                    .and_then(|floor| floor.latest_schema_version),
13126            },
13127            replica: ClientDiagnosticsReplica {
13128                local_revision: self.local_revision().to_string(),
13129                sync_needed: self.sync_needed,
13130                pending_outbox: self.outbox.len(),
13131            },
13132            lease,
13133            subscriptions: ordered_subscriptions,
13134            subscriptions_truncated,
13135            last_round: self.last_round.clone(),
13136            last_change: self.last_change.clone(),
13137            storage: self.diagnostics_storage(),
13138            query_failures: self.query_failures.clone(),
13139        })
13140    }
13141
13142    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
13143        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
13144            let page_count: i64 = self
13145                .conn
13146                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
13147            let page_size: i64 = self
13148                .conn
13149                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
13150            // Reuse compiled aggregates; every invocation still reads current storage.
13151            let outbox_bytes: i64 = self
13152                .conn
13153                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
13154                .query_row([], |row| row.get(0))?;
13155            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
13156                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
13157            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
13158            let blob_bytes = if self.schema_has_blobs() {
13159                self.conn
13160                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
13161                    .query_row([], |row| row.get(0))?
13162            } else {
13163                0
13164            };
13165            let pressure = self
13166                .limits
13167                .blob_cache_max_bytes
13168                .is_some_and(|limit| blob_bytes > limit);
13169            Ok(ClientDiagnosticsStorage {
13170                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
13171                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
13172                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
13173                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
13174                retained_outcome_entries: Some(outcome_entries.max(0)),
13175                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
13176                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
13177            })
13178        };
13179        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
13180            status: "unreadable".to_owned(),
13181            database_bytes_approx: None,
13182            pending_outbox_bytes_approx: None,
13183            retained_outcome_bytes_approx: None,
13184            retained_outcome_entries: None,
13185            blob_cache_bytes_approx: None,
13186            pressure_reason_code: None,
13187        })
13188    }
13189
13190    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
13191        self.change_queue.drain(..).collect()
13192    }
13193
13194    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
13195        let intents = self.sync_intent_queue.drain(..).collect();
13196        if self.transport_enabled {
13197            intents
13198        } else {
13199            vec![SyncIntent::None]
13200        }
13201    }
13202
13203    fn schedule_background_retry(&mut self) -> u64 {
13204        let delay_ms = self.retry_delay_ms;
13205        self.sync_intent_queue
13206            .push_back(SyncIntent::Background { delay_ms });
13207        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
13208        self.round_retry_delay_ms = Some(delay_ms);
13209        delay_ms
13210    }
13211
13212    fn reset_background_retry(&mut self) {
13213        self.retry_delay_ms = 250;
13214    }
13215
13216    fn retryable_transport_code(code: &str) -> bool {
13217        code == "transport.failed"
13218            || code == "transport.timeout"
13219            || code == "transport.unavailable"
13220            || code == "sync.transport_failed"
13221    }
13222
13223    /// §1.8: the transport reported a refused or unreachable endpoint. The
13224    /// native transport says `connect` (DNS failure, connection refused,
13225    /// reset, or aborted); a browser fetch transport says `network`. Repeating
13226    /// the request on a backoff repairs neither, so no retry is scheduled.
13227    fn unreachable_transport(details: &Option<serde_json::Value>) -> bool {
13228        matches!(
13229            details
13230                .as_ref()
13231                .and_then(|details| details.get("causeKind"))
13232                .and_then(serde_json::Value::as_str),
13233            Some("connect" | "network")
13234        )
13235    }
13236
13237    /// §1.5: a segment fetch that fails inside an otherwise complete round is
13238    /// invisible to the server (the round response arrived whole), so its code
13239    /// rides the following requests like a socket-round failure. A code the
13240    /// frame grammar rejects is not reportable and the previous code stays.
13241    fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
13242        if ssp2::model::valid_round_failure_code(&error.code) {
13243            self.round_failure = Some(error.code.clone());
13244        }
13245        SectionError::Abort(error.code, error.message)
13246    }
13247
13248    /// A failed round worth a background retry: a transport failure, or a
13249    /// §10.2 code the catalog marks retryable (clients may hardcode the
13250    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
13251    /// on the server's `retryable` flag.
13252    fn retryable_failure_code(code: &str) -> bool {
13253        Self::retryable_transport_code(code)
13254            || matches!(
13255                code,
13256                "sync.auth_required"
13257                    | "sync.auth_lease_required"
13258                    | "sync.auth_lease_revoked"
13259                    | "sync.internal_error"
13260                    | "sync.idempotency_cache_miss"
13261                    | "sync.schema_not_ready"
13262                    | "sync.segment_expired"
13263                    | "sync.rate_limited"
13264                    | "sync.websocket_connection_limit"
13265            )
13266    }
13267
13268    fn diagnostic_code(code: &str) -> String {
13269        let valid = !code.is_empty()
13270            && code.len() <= 96
13271            && code.contains('.')
13272            && code
13273                .bytes()
13274                .next()
13275                .is_some_and(|byte| byte.is_ascii_lowercase())
13276            && code.bytes().all(|byte| {
13277                byte.is_ascii_lowercase()
13278                    || byte.is_ascii_digit()
13279                    || matches!(byte, b'.' | b'_' | b'-')
13280            });
13281        if valid {
13282            code.to_owned()
13283        } else {
13284            "client.unknown_failure".to_owned()
13285        }
13286    }
13287
13288    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
13289        if self.sync_needed != value {
13290            if self.begin_observation("syncular_status").is_ok() {
13291                self.sync_needed = value;
13292                let batch = ChangeAccumulator {
13293                    status: true,
13294                    ..ChangeAccumulator::default()
13295                };
13296                if self.finish_observation("syncular_status", batch).is_err() {
13297                    self.rollback_observation("syncular_status");
13298                }
13299            } else {
13300                self.sync_needed = value;
13301            }
13302        }
13303        if value && interactive {
13304            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13305        }
13306    }
13307
13308    fn sqlite_failure(
13309        storage_failure: &RefCell<Option<QueryReadFailure>>,
13310        error: rusqlite::Error,
13311    ) -> String {
13312        let message = error.to_string();
13313        let failure = QueryReadFailure::from(error);
13314        let code = failure.code;
13315        if code.is_some() && storage_failure.borrow().is_none() {
13316            *storage_failure.borrow_mut() = Some(failure);
13317        }
13318        match code {
13319            Some(code) => format!("{code}: {message}"),
13320            None => message,
13321        }
13322    }
13323
13324    fn begin_observation(&self, name: &str) -> Result<(), String> {
13325        if let Some(failure) = &self.coalesced_failure {
13326            return Err(failure.clone());
13327        }
13328        // SQLite rolls a whole transaction back on some I/O and capacity
13329        // errors; later frames must not commit on their own after that.
13330        if self.coalesced.is_some() && self.conn.is_autocommit() {
13331            return Err("the coalesced apply transaction was rolled back".to_owned());
13332        }
13333        self.conn
13334            .execute_batch(&format!("SAVEPOINT {name}"))
13335            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13336    }
13337
13338    /// SPEC §1.4 rule 4: open a coalesced apply transaction unless a caller
13339    /// already owns one.
13340    fn begin_coalesced_apply(&mut self) -> Result<(), String> {
13341        if self.coalesced.is_some() || !self.conn.is_autocommit() {
13342            return Ok(());
13343        }
13344        self.conn
13345            .execute_batch("BEGIN")
13346            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13347        self.coalesced = Some(CoalescedApply {
13348            rows: 0,
13349            subs: self.subs.clone(),
13350            outbox: self.outbox.clone(),
13351            failed_commits: self.failed_commits.clone(),
13352            conflicts: self.conflicts.clone(),
13353            rejections: self.rejections.clone(),
13354            schema_floor: self.schema_floor.clone(),
13355            lease_state: self.lease_state.clone(),
13356            stopped: self.stopped,
13357            upgrading: self.upgrading,
13358            sync_needed: self.sync_needed,
13359            overlay_dirty: self.overlay_dirty.snapshot(),
13360            change_queue: self.change_queue.len(),
13361            sync_intent_queue: self.sync_intent_queue.len(),
13362            last_change: self.last_change.clone(),
13363        });
13364        Ok(())
13365    }
13366
13367    /// Account the rows of the next frame or block. A unit that would take the
13368    /// coalesced transaction past the bound commits the earlier units first.
13369    fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
13370        let Some(open) = &self.coalesced else {
13371            return Ok(());
13372        };
13373        if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
13374            self.commit_coalesced_apply()?;
13375            self.begin_coalesced_apply()?;
13376        }
13377        if let Some(open) = &mut self.coalesced {
13378            open.rows += rows;
13379        }
13380        Ok(())
13381    }
13382
13383    /// COMMIT the coalesced transaction. A failed COMMIT rolls every coalesced
13384    /// frame back, restores the in-memory state of its `BEGIN`, drops the
13385    /// change batches and sync intents queued since, and fails the rest of the
13386    /// response.
13387    fn commit_coalesced_apply(&mut self) -> Result<(), String> {
13388        let Some(open) = self.coalesced.take() else {
13389            return Ok(());
13390        };
13391        let committed = if self.conn.is_autocommit() {
13392            Err("the coalesced apply transaction was rolled back".to_owned())
13393        } else {
13394            self.conn
13395                .execute_batch("COMMIT")
13396                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13397        };
13398        let Err(error) = committed else {
13399            return Ok(());
13400        };
13401        if !self.conn.is_autocommit() {
13402            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13403                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13404            }
13405        }
13406        self.subs = open.subs;
13407        self.outbox = open.outbox;
13408        self.failed_commits = open.failed_commits;
13409        self.conflicts = open.conflicts;
13410        self.rejections = open.rejections;
13411        self.schema_floor = open.schema_floor;
13412        self.lease_state = open.lease_state;
13413        self.stopped = open.stopped;
13414        self.upgrading = open.upgrading;
13415        self.sync_needed = open.sync_needed;
13416        self.overlay_dirty.restore(open.overlay_dirty);
13417        self.change_queue.truncate(open.change_queue);
13418        self.sync_intent_queue.truncate(open.sync_intent_queue);
13419        self.last_change = open.last_change;
13420        self.coalesced_failure = Some(error.clone());
13421        Err(error)
13422    }
13423
13424    /// End the response's coalescing: COMMIT what is open and clear a failure.
13425    fn finish_coalesced_apply(&mut self) -> Result<(), String> {
13426        let committed = self.commit_coalesced_apply();
13427        match self.coalesced_failure.take() {
13428            Some(failure) => Err(failure),
13429            None => committed,
13430        }
13431    }
13432
13433    fn rollback_observation(&self, name: &str) {
13434        if let Err(error) = self
13435            .conn
13436            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
13437        {
13438            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
13439                if failure.rollback_failure.is_none() {
13440                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
13441                }
13442            }
13443        }
13444    }
13445
13446    fn finish_observation(
13447        &mut self,
13448        name: &str,
13449        mut batch: ChangeAccumulator,
13450    ) -> Result<(), String> {
13451        #[cfg(feature = "bench-internals")]
13452        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
13453        if self.failed_commits.iter().any(|(commit, _)| {
13454            commit
13455                .ops
13456                .iter()
13457                .any(|operation| batch.tables.contains_key(&operation.table))
13458        }) {
13459            batch.outcomes = true;
13460        }
13461        if !batch.touched() {
13462            self.conn
13463                .execute_batch(&format!("RELEASE {name}"))
13464                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13465            return Ok(());
13466        }
13467        let revision = self
13468            .local_revision()
13469            .checked_add(1)
13470            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
13471        self.conn
13472            .execute(
13473                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13474                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
13475            )
13476            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13477        let status = batch.status.then(|| self.status_snapshot());
13478        let event = ClientChangeBatch {
13479            revision: revision.to_string(),
13480            tables: batch
13481                .tables
13482                .into_iter()
13483                .map(|(table, scope_keys)| TableChange {
13484                    table,
13485                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
13486                })
13487                .collect(),
13488            windows: batch
13489                .windows
13490                .into_iter()
13491                .map(|((base_key, table), units)| WindowChange {
13492                    base_key,
13493                    table,
13494                    units: units.into_iter().collect(),
13495                })
13496                .collect(),
13497            status,
13498            conflicts_changed: batch.conflicts,
13499            rejections_changed: batch.rejections,
13500            outcomes_changed: batch.outcomes,
13501        };
13502        self.conn
13503            .execute_batch(&format!("RELEASE {name}"))
13504            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13505        let mut diagnostic_tables = event
13506            .tables
13507            .iter()
13508            .map(|entry| entry.table.clone())
13509            .collect::<BTreeSet<_>>()
13510            .into_iter()
13511            .collect::<Vec<_>>();
13512        let mut diagnostic_windows = event
13513            .windows
13514            .iter()
13515            .map(|entry| entry.table.clone())
13516            .collect::<BTreeSet<_>>()
13517            .into_iter()
13518            .collect::<Vec<_>>();
13519        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
13520            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
13521        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
13522        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
13523        self.last_change = Some(DiagnosticLastChange {
13524            revision: event.revision.clone(),
13525            recorded_at_ms: self.clock_now_ms(),
13526            tables: diagnostic_tables,
13527            windows: diagnostic_windows,
13528            domains_truncated,
13529            status_changed: event.status.is_some(),
13530            conflicts_changed: event.conflicts_changed,
13531            rejections_changed: event.rejections_changed,
13532            outcomes_changed: event.outcomes_changed,
13533        });
13534        self.change_queue.push_back(event);
13535        Ok(())
13536    }
13537
13538    fn record_scope_map(
13539        &self,
13540        batch: &mut ChangeAccumulator,
13541        table_name: &str,
13542        scopes: &[(String, Vec<String>)],
13543    ) {
13544        let Some(table) = self.schema.table(table_name) else {
13545            return;
13546        };
13547        for (variable, values) in scopes {
13548            let Some(scope) = table
13549                .scope_variables
13550                .iter()
13551                .find(|scope| &scope.variable == variable)
13552            else {
13553                continue;
13554            };
13555            for value in values {
13556                batch.scope(table_name, format!("{}:{value}", scope.prefix));
13557            }
13558        }
13559    }
13560
13561    /// Record a row's current scope keys from the base or visible table.
13562    fn record_row_scopes(
13563        &self,
13564        batch: &mut ChangeAccumulator,
13565        table_name: &str,
13566        row_id: &str,
13567        base: bool,
13568    ) -> bool {
13569        let Some(table) = self.schema.table(table_name) else {
13570            return false;
13571        };
13572        if table.scope_variables.is_empty() {
13573            return false;
13574        }
13575        let columns = table
13576            .scope_variables
13577            .iter()
13578            .map(|scope| quote_ident(&scope.column))
13579            .collect::<Vec<_>>()
13580            .join(", ");
13581        let full_table = if base {
13582            base_table(table_name)
13583        } else {
13584            visible_table(table_name)
13585        };
13586        let sql = format!(
13587            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
13588            row_id_predicate(table)
13589        );
13590        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
13591            return false;
13592        };
13593        let values = stmt.query_row(rusqlite::params![row_id], |row| {
13594            let mut values = Vec::with_capacity(table.scope_variables.len());
13595            for index in 0..table.scope_variables.len() {
13596                values.push(row.get::<_, Option<String>>(index)?);
13597            }
13598            Ok(values)
13599        });
13600        let Ok(values) = values else {
13601            return false;
13602        };
13603        let mut recorded = false;
13604        for (scope, value) in table.scope_variables.iter().zip(values) {
13605            if let Some(value) = value {
13606                batch.scope(table_name, format!("{}:{value}", scope.prefix));
13607                recorded = true;
13608            }
13609        }
13610        recorded
13611    }
13612
13613    fn record_commit_changes(
13614        &self,
13615        batch: &mut ChangeAccumulator,
13616        tables: &[String],
13617        changes: &[ssp2::model::Change],
13618    ) {
13619        #[cfg(feature = "bench-internals")]
13620        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
13621        for change in changes {
13622            let Some(table_name) = tables.get(change.table_index as usize) else {
13623                continue;
13624            };
13625            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
13626            if let Some(table) = self.schema.table(table_name) {
13627                for (variable, value) in &change.scopes {
13628                    if let Some(scope) = table
13629                        .scope_variables
13630                        .iter()
13631                        .find(|scope| &scope.variable == variable)
13632                    {
13633                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
13634                        precise = true;
13635                    }
13636                }
13637            }
13638            if !precise {
13639                batch.table(table_name);
13640            }
13641        }
13642    }
13643
13644    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
13645        if effective.is_empty() {
13646            return false;
13647        }
13648        let Some(table) = self.schema.table(table_name) else {
13649            return false;
13650        };
13651        let mut clauses = Vec::new();
13652        let mut params = Vec::new();
13653        for (variable, values) in effective {
13654            let Some(column) = table.scope_column(variable) else {
13655                return false;
13656            };
13657            if values.is_empty() {
13658                return false;
13659            }
13660            let placeholders = values
13661                .iter()
13662                .map(|value| {
13663                    params.push(SqlValue::Text(value.clone()));
13664                    "?"
13665                })
13666                .collect::<Vec<_>>()
13667                .join(", ");
13668            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
13669        }
13670        let sql = format!(
13671            "SELECT 1 FROM {} WHERE {} LIMIT 1",
13672            base_table(table_name),
13673            clauses.join(" AND ")
13674        );
13675        self.conn
13676            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
13677            .is_ok()
13678    }
13679
13680    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
13681    pub fn upgrading(&self) -> bool {
13682        self.upgrading
13683    }
13684
13685    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
13686    /// keeping this client's local database (identity, outbox, tables). The
13687    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
13688    /// version increases. Recreation runs the same marker guard as opening
13689    /// a durable replica, holds the guard and every write it authorizes under
13690    /// one write transaction, and preserves the current client (compiled
13691    /// schema, outbox, tables) on refusal.
13692    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
13693        let new_schema = parse_schema_json(schema_json)?;
13694        validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
13695        self.conn
13696            .execute_batch("BEGIN IMMEDIATE")
13697            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13698        let prior_schema = std::mem::replace(&mut self.schema, new_schema);
13699        // §7.4.3/§7.4.4: a reset inside this transaction mutates in-memory
13700        // state (dropped outbox commits, rejections, upgrade and floor flags,
13701        // overlay dirtiness) and appends change/intent batches. A COMMIT that
13702        // fails after the reset succeeded rolls the database back, so every
13703        // one of those fields is captured here and restored with it.
13704        let prior_subs = self.subs.clone();
13705        let prior_outbox = self.outbox.clone();
13706        let prior_rejections = self.rejections.clone();
13707        let prior_upgrading = self.upgrading;
13708        let prior_stopped = self.stopped;
13709        let prior_schema_floor = self.schema_floor.clone();
13710        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13711        let prior_sync_needed = self.sync_needed;
13712        let prior_active_round = self.active_round;
13713        let prior_changes = self.change_queue.len();
13714        let prior_intents = self.sync_intent_queue.len();
13715        let prior_last_change = self.last_change.clone();
13716        let guarded = (|| -> Result<(), String> {
13717            // §7.4.2: the marker is re-read under the write lock the caller
13718            // holds, so a replica another process upgraded meanwhile refuses
13719            // this recreation instead of having its marker and tables
13720            // downgraded.
13721            let marker = read_local_schema_version(&self.conn, self.schema.version)?;
13722            self.create_bookkeeping_tables()?;
13723            match marker {
13724                Some(version) if version < self.schema.version => self.run_schema_reset()?,
13725                None => {
13726                    self.create_synced_tables()?;
13727                    self.persist_schema_version()?;
13728                }
13729                _ => {}
13730            }
13731            // §7.4.2: recreation IS the boot for the in-memory core, so reload the
13732            // durable outbox the same-version path is about to replay. Only the
13733            // outbox is reloaded: the recovery below reads it, and the caller's
13734            // rollback snapshot already covers this field.
13735            self.outbox = self.load_outbox()?;
13736            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
13737            // this guarded boundary once the schema and marker agree. A bump went
13738            // through `run_schema_reset`, which performs the same recovery after
13739            // it creates the new physical tables and before its final replay.
13740            self.drop_unencodable_outbox(false, None)?;
13741            if marker == Some(self.schema.version)
13742                && (!self.outbox.is_empty()
13743                    || !self.failed_commits.is_empty()
13744                    || self.has_acknowledged_rows())
13745            {
13746                self.overlay_dirty.set(true);
13747                self.rebuild_overlay()?;
13748            }
13749            self.prune_unknown_subscriptions(false)?;
13750            self.save_subscription_scope_schema();
13751            // The conformance recreate is the in-memory equivalent of reopening
13752            // a durable client. Apply the same startup catch-up contract even
13753            // when the schema itself did not change.
13754            self.enqueue_startup_sync_if_needed()?;
13755            Ok(())
13756        })();
13757        let outcome = match guarded {
13758            Ok(()) => self
13759                .conn
13760                .execute_batch("COMMIT")
13761                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
13762            Err(error) => Err(error),
13763        };
13764        if let Err(error) = outcome {
13765            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13766                // Keep a failed rollback's typed storage classification.
13767                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13768            }
13769            self.schema = prior_schema;
13770            self.subs = prior_subs;
13771            self.outbox = prior_outbox;
13772            self.rejections = prior_rejections;
13773            self.upgrading = prior_upgrading;
13774            self.stopped = prior_stopped;
13775            self.schema_floor = prior_schema_floor;
13776            self.overlay_dirty.restore(prior_overlay_dirty);
13777            self.sync_needed = prior_sync_needed;
13778            self.active_round = prior_active_round;
13779            self.change_queue.truncate(prior_changes);
13780            self.sync_intent_queue.truncate(prior_intents);
13781            self.last_change = prior_last_change;
13782            // Prepared insert statements were derived from the reverted schema.
13783            self.insert_sql.borrow_mut().clear();
13784            return Err(error);
13785        }
13786        Ok(())
13787    }
13788
13789    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
13790        let startup_work = !self.stopped
13791            && (!self.outbox.is_empty()
13792                || self.has_acknowledged_rows()
13793                || !self.load_pending_evictions()?.is_empty()
13794                || self.subs.iter().any(|sub| sub.state == SubState::Active));
13795        if startup_work {
13796            self.sync_needed = true;
13797            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13798        }
13799        Ok(())
13800    }
13801
13802    /// A native client persists schema-floor stops across process restarts.
13803    /// Once the running app already satisfies that floor, the persisted stop
13804    /// is only stale evidence from an older server round (for example, the
13805    /// server was restarted after catching up to an app that was ahead).
13806    /// Clear it and let the normal startup pull re-negotiate. If the server is
13807    /// still incompatible it will return the floor again in that first round.
13808    fn clear_satisfied_persisted_schema_floor(&mut self) {
13809        let satisfied = self
13810            .schema_floor
13811            .as_ref()
13812            .and_then(|floor| floor.required_schema_version)
13813            .is_some_and(|required| self.schema.version >= required);
13814        if !satisfied {
13815            return;
13816        }
13817        self.schema_floor = None;
13818        self.stopped = false;
13819        self.delete_meta(SCHEMA_FLOOR_KEY);
13820    }
13821
13822    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
13823    /// path already swept it; this covers a same-version or fresh-install open,
13824    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
13825    /// an aware binary can apply.
13826    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
13827        let Some(replica_path) = self.previous_version_replica_path() else {
13828            return Ok(());
13829        };
13830        let max_age_ms = self
13831            .previous_version
13832            .filter(|config| config.enabled)
13833            .map(|config| config.max_age_ms);
13834        reconcile_previous_version_at_boot(
13835            &self.conn,
13836            &replica_path,
13837            self.schema.version,
13838            max_age_ms,
13839            self.clock_now_ms(),
13840        )
13841    }
13842
13843    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
13844    /// no file — an in-memory replica returns an empty path, and a `file:` URI
13845    /// has no plain sibling path — so the feature resolves as off there.
13846    fn previous_version_replica_path(&self) -> Option<String> {
13847        let path = self.conn.path()?;
13848        if path.is_empty() || path.starts_with("file:") {
13849            return None;
13850        }
13851        Some(path.to_owned())
13852    }
13853
13854    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
13855    /// and leaseState. Drops/recreates every synced table from the new
13856    /// schema, resets subscription sync-state (keeping registrations), clears
13857    /// the schema-floor stop state, rewrites the marker, drops outbox commits
13858    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
13859    fn run_schema_reset(&mut self) -> Result<(), String> {
13860        self.begin_observation("syncular_schema_reset")?;
13861        let prior_subs = self.subs.clone();
13862        let prior_outbox = self.outbox.clone();
13863        let prior_rejections = self.rejections.clone();
13864        let prior_upgrading = self.upgrading;
13865        let prior_stopped = self.stopped;
13866        let prior_schema_floor = self.schema_floor.clone();
13867        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13868        let prior_sync_needed = self.sync_needed;
13869        let prior_active_round = self.active_round;
13870        let mut batch = ChangeAccumulator::default();
13871        let result = self
13872            .run_schema_reset_observed(&mut batch, true)
13873            .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
13874        if let Err(error) = result {
13875            self.rollback_observation("syncular_schema_reset");
13876            self.subs = prior_subs;
13877            self.outbox = prior_outbox;
13878            self.rejections = prior_rejections;
13879            self.upgrading = prior_upgrading;
13880            self.stopped = prior_stopped;
13881            self.schema_floor = prior_schema_floor;
13882            self.overlay_dirty.restore(prior_overlay_dirty);
13883            self.sync_needed = prior_sync_needed;
13884            self.active_round = prior_active_round;
13885            return Err(error);
13886        }
13887        Ok(())
13888    }
13889
13890    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
13891        if self.get_meta(LOG_EPOCH_KEY).is_none() {
13892            self.begin_observation("syncular_epoch_acquisition")?;
13893            let previous_sync_needed = self.sync_needed;
13894            if let Err(error) = self.conn.execute(
13895                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13896                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13897            ) {
13898                self.rollback_observation("syncular_epoch_acquisition");
13899                return Err(Self::sqlite_failure(&self.storage_failure, error));
13900            }
13901            self.sync_needed = true;
13902            let batch = ChangeAccumulator {
13903                status: true,
13904                ..ChangeAccumulator::default()
13905            };
13906            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
13907                self.rollback_observation("syncular_epoch_acquisition");
13908                self.sync_needed = previous_sync_needed;
13909                return Err(error);
13910            }
13911            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13912            return Ok(Vec::new());
13913        }
13914        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
13915        self.begin_observation("syncular_log_epoch_reset")?;
13916        let prior_subs = self.subs.clone();
13917        let prior_outbox = self.outbox.clone();
13918        let prior_rejections = self.rejections.clone();
13919        let prior_upgrading = self.upgrading;
13920        let prior_stopped = self.stopped;
13921        let prior_schema_floor = self.schema_floor.clone();
13922        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13923        let prior_sync_needed = self.sync_needed;
13924        let prior_active_round = self.active_round;
13925        let mut batch = ChangeAccumulator::default();
13926        let result = self
13927            .run_schema_reset_observed(&mut batch, false)
13928            .and_then(|()| {
13929                self.conn
13930                    .execute(
13931                        "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13932                        rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13933                    )
13934                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13935                self.sync_needed = true;
13936                batch.status = true;
13937                self.finish_observation("syncular_log_epoch_reset", batch)
13938            });
13939        if let Err(error) = result {
13940            self.rollback_observation("syncular_log_epoch_reset");
13941            self.subs = prior_subs;
13942            self.outbox = prior_outbox;
13943            self.rejections = prior_rejections;
13944            self.upgrading = prior_upgrading;
13945            self.stopped = prior_stopped;
13946            self.schema_floor = prior_schema_floor;
13947            self.overlay_dirty.restore(prior_overlay_dirty);
13948            self.sync_needed = prior_sync_needed;
13949            self.active_round = prior_active_round;
13950            return Err(error);
13951        }
13952        self.sync_intent_queue.push_back(SyncIntent::Interactive);
13953        Ok(resets)
13954    }
13955
13956    fn run_schema_reset_observed(
13957        &mut self,
13958        batch: &mut ChangeAccumulator,
13959        capture: bool,
13960    ) -> Result<(), String> {
13961        // §7.4.2: the reset is the only path that wipes local tables for a
13962        // version bump, and it rewrites the marker last. Re-read the marker
13963        // under this reset's transaction: a replica another process upgraded
13964        // meanwhile refuses here instead of being reset to this client's
13965        // older schema.
13966        read_local_schema_version(&self.conn, self.schema.version)?;
13967        self.conn
13968            .execute_batch(
13969                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
13970            )
13971            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13972        self.cancel_sync_round();
13973        self.upgrading = true;
13974        batch.status = true;
13975        // RFC 0005 D6: classify the pending outbox against the NEW compiled
13976        // schema BEFORE the wipe, and record the advisory audit. This pass
13977        // drops nothing, so the audit sees the original pending intent; the
13978        // §7.4.4 send-time drop and the intrinsic value recovery later in this
13979        // reset are the paths that remove a commit. The classification is
13980        // shared with the send-time drop so the two cannot disagree about what
13981        // is incompatible.
13982        if self.previous_version.is_some_and(|config| config.enabled) {
13983            let previous_version = self
13984                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13985                .and_then(|value| value.parse::<i32>().ok())
13986                .unwrap_or(self.schema.version);
13987            let pending = self
13988                .outbox
13989                .iter()
13990                .map(|commit| PendingCommitAudit {
13991                    commit_id: &commit.client_commit_id,
13992                    operations: commit_audit_operations(commit),
13993                })
13994                .collect::<Vec<_>>();
13995            crate::previous_version::write_previous_version_audit(
13996                &self.conn,
13997                &crate::previous_version::build_previous_version_audit(
13998                    &self.schema,
13999                    &pending,
14000                    previous_version,
14001                    self.schema.version,
14002                    self.clock_now_ms(),
14003                ),
14004            );
14005        }
14006        // RFC 0005 D5: the container lives in its own FILE, so the reset can
14007        // never reach it. Sweep first (unconditional — also on the log-epoch
14008        // reset, which captures nothing), then capture BEFORE the wipe drops
14009        // any row. A refused capture records its reason and leaves no file.
14010        if let Some(replica_path) = self.previous_version_replica_path() {
14011            sweep_previous_version_container(&self.conn, &replica_path)?;
14012            if capture {
14013                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
14014                    let previous_version = self
14015                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
14016                        .and_then(|value| value.parse::<i32>().ok())
14017                        .unwrap_or(self.schema.version);
14018                    capture_previous_version_from_replica(
14019                        &self.conn,
14020                        &replica_path,
14021                        previous_version,
14022                        self.schema.version,
14023                        &config,
14024                        self.clock_now_ms(),
14025                    )?;
14026                }
14027            }
14028        }
14029        for table in &self.schema.tables {
14030            batch.table(&table.name);
14031        }
14032        for (base_key, unit, table) in self.load_registered_window_units() {
14033            batch.window(&base_key, &table, &unit);
14034        }
14035        if capture {
14036            self.prune_unknown_subscriptions(true)?;
14037        }
14038        // The per-table insert SQL is derived from the OLD column lists.
14039        self.insert_sql.borrow_mut().clear();
14040        self.overlay_dirty.set(true);
14041        // Drop every synced table (base + visible) that currently exists —
14042        // discovered from sqlite_master so a bump that adds/removes tables is
14043        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
14044        // and the blob cache) are preserved; base tables are `_syncular_base_*`
14045        // so they are matched explicitly, not by the bookkeeping filter.
14046        // Drop virtual tables first so SQLite removes every FTS shadow table
14047        // atomically instead of the generic discovery tearing it apart.
14048        let virtual_tables: Vec<String> = {
14049            let mut stmt = self
14050                .conn
14051                .prepare(
14052                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
14053                )
14054                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14055            let rows = stmt
14056                .query_map([], |row| row.get::<_, String>(0))
14057                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14058            rows.filter_map(Result::ok)
14059                .filter(|name| is_synced_table_name(name))
14060                .collect()
14061        };
14062        for name in virtual_tables {
14063            self.conn
14064                .execute_batch(&format!(
14065                    "DROP TABLE IF EXISTS {}",
14066                    quote_ident(&format!("_syncular_fts_{name}"))
14067                ))
14068                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14069            self.conn
14070                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
14071                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14072        }
14073        let existing: Vec<String> = {
14074            let mut stmt = self
14075                .conn
14076                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
14077                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14078            let rows = stmt
14079                .query_map([], |row| row.get::<_, String>(0))
14080                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14081            rows.filter_map(Result::ok)
14082                .filter(|name| is_synced_table_name(name))
14083                .collect()
14084        };
14085        for name in &existing {
14086            if let Some(table) = name.strip_prefix("_syncular_base_") {
14087                batch.table(table);
14088            } else if !name.starts_with("_syncular_") {
14089                batch.table(name);
14090            }
14091        }
14092        for name in existing {
14093            self.conn
14094                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
14095                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14096        }
14097        // Recreate the synced tables from the NEW schema.
14098        self.create_synced_tables()?;
14099        // Reset every subscription's sync-state, keeping the registration.
14100        for sub in &mut self.subs {
14101            sub.cursor = -1;
14102            sub.bootstrap_state = None;
14103            sub.effective = None;
14104            sub.state = SubState::Active;
14105            sub.reason_code = None;
14106            sub.synced_once = false;
14107        }
14108        let subs = self.subs.clone();
14109        for sub in &subs {
14110            self.persist_sub(sub)?;
14111        }
14112        // The stop state is over: this client now ships a servable schema.
14113        self.stopped = false;
14114        self.schema_floor = None;
14115        self.delete_meta(SCHEMA_FLOOR_KEY);
14116        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
14117        self.persist_schema_version()?;
14118        self.save_subscription_scope_schema();
14119        // §7.1/§7.4.4: the previous-version capture and audit above saw the
14120        // original pending intent; the synced tables now carry the new schema, so
14121        // recover intrinsically codec-invalid persisted values here, before the
14122        // final replay. A pending upsert that names a column the new schema
14123        // removed stays pending for the send-time `drop_incompatible_outbox`
14124        // prepass. The reset wrapper snapshots and restores the outbox,
14125        // rejections, and change queues, so a failed commit rolls this recovery
14126        // back with the rest of the reset.
14127        // §7.4.4: the enclosing reset observation owns the publication, so the
14128        // recovery contributes its rejection, outcome, and table domains to it.
14129        self.drop_unencodable_outbox(false, Some(batch))?;
14130        // Re-apply the surviving outbox optimistically over the empty tables.
14131        self.rebuild_overlay()?;
14132        Ok(())
14133    }
14134
14135    /// §7.4.4 send-time prepass: a persisted upsert whose values reference a
14136    /// column the current schema lacks (or a removed table) cannot be encoded
14137    /// (`commit_audit_operations` inspects upserts, so a value-free delete stays
14138    /// encodable and is validated by the server), nor can an insert-intent
14139    /// upsert that lacks a non-nullable column (§7.1, the audit's
14140    /// `missing-required-column`). Drop the commit, undo
14141    /// its optimistic projection, and raise a client-local
14142    /// `sync.outbox_incompatible` rejection.
14143    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
14144        let incompatible = self
14145            .outbox
14146            .iter()
14147            .filter(|commit| {
14148                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
14149            })
14150            .cloned()
14151            .collect::<Vec<_>>();
14152        if incompatible.is_empty() {
14153            return Ok(false);
14154        }
14155        // §7.4.4: one observation covers the durable outcome writes, the outbox
14156        // removal, the in-memory drop, and the overlay rebuild, so a storage
14157        // fault leaves the replica and the client unchanged and publishes no
14158        // change; a successful drop publishes exactly one batch carrying the
14159        // rejections, outcomes, status, and the tables whose projection changed.
14160        let prior_outbox = self.outbox.clone();
14161        let prior_rejections = self.rejections.clone();
14162        let prior_overlay_dirty = self.overlay_dirty.snapshot();
14163        let owns_transaction = self.conn.is_autocommit();
14164        self.begin_observation("syncular_outbox_drop")?;
14165        let mut batch = ChangeAccumulator::default();
14166        let dropped = (|| -> Result<(), String> {
14167            let mut rejections = Vec::new();
14168            for commit in &incompatible {
14169                let results = commit
14170                    .ops
14171                    .iter()
14172                    .enumerate()
14173                    .map(|(op_index, operation)| {
14174                        let rejection = RejectionRecord {
14175                            client_commit_id: commit.client_commit_id.clone(),
14176                            op_index: op_index as i32,
14177                            code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
14178                            message: "the persisted commit cannot encode under the current schema"
14179                                .to_owned(),
14180                            retryable: false,
14181                            details: None,
14182                            operation: Some(CommitOperation::from(operation)),
14183                        };
14184                        rejections.push(rejection.clone());
14185                        CommitOperationOutcome::Error { rejection }
14186                    })
14187                    .collect::<Vec<_>>();
14188                self.persist_commit_outcome(
14189                    &commit.client_commit_id,
14190                    CommitOutcomeStatus::Rejected,
14191                    &results,
14192                    Some(&commit.ops),
14193                )?;
14194                self.delete_outbox_persisted(&commit.client_commit_id)?;
14195                // A removed table has no local projection to invalidate; every
14196                // surviving table the dropped commit touched does.
14197                for operation in &commit.ops {
14198                    if self.schema.table(&operation.table).is_some() {
14199                        batch.table(&operation.table);
14200                    }
14201                }
14202            }
14203            self.prune_commit_outcomes()?;
14204            let incompatible_ids = incompatible
14205                .iter()
14206                .map(|commit| commit.client_commit_id.as_str())
14207                .collect::<BTreeSet<_>>();
14208            self.outbox
14209                .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
14210            self.rejections.extend(rejections);
14211            // The dropped commits' purely-optimistic rows are undone: the
14212            // visible projection is re-derived from the surviving base + outbox.
14213            self.overlay_dirty.set(true);
14214            self.rebuild_overlay()?;
14215            batch.status = true;
14216            batch.rejections = true;
14217            batch.outcomes = true;
14218            Ok(())
14219        })();
14220        let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
14221        if let Err(error) = result {
14222            // An outer RELEASE is the COMMIT. Roll back that transaction
14223            // directly: retrying RELEASE after ROLLBACK TO still needs the
14224            // reader's lock. A nested observation only rolls back its savepoint.
14225            if owns_transaction {
14226                if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14227                    let mut failure = self.storage_failure.borrow_mut();
14228                    let failure =
14229                        failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14230                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14231                }
14232            } else {
14233                self.rollback_observation("syncular_outbox_drop");
14234            }
14235            self.outbox = prior_outbox;
14236            self.rejections = prior_rejections;
14237            self.overlay_dirty.restore(prior_overlay_dirty);
14238            return Err(error);
14239        }
14240        Ok(true)
14241    }
14242
14243    /// §7.4.3: (re)create the base + visible table pair for every synced
14244    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
14245    fn create_synced_tables(&self) -> Result<(), String> {
14246        for table in &self.schema.tables {
14247            // The base half + the visible half form the synced-table pair. An
14248            // index name is global in SQLite, so the base half's indexes are
14249            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
14250            for (full, index_prefix) in [
14251                (base_table(&table.name), "_syncular_base_"),
14252                (visible_table(&table.name), ""),
14253            ] {
14254                let mut cols: Vec<String> =
14255                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
14256                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
14257                let sql = format!(
14258                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
14259                    cols.join(", "),
14260                    quote_ident(&table.primary_key)
14261                );
14262                self.conn
14263                    .execute(&sql, [])
14264                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14265                // Local secondary indexes (CREATE INDEX subset). Created on
14266                // both halves so mirror reads hit an index on either. Runs on
14267                // both the initial create and the §7.4.3 reset recreate path.
14268                for index in &table.indexes {
14269                    let unique = if index.unique { "UNIQUE " } else { "" };
14270                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
14271                    let cols_sql = index
14272                        .columns
14273                        .iter()
14274                        .map(|c| quote_ident(c))
14275                        .collect::<Vec<_>>()
14276                        .join(", ");
14277                    let index_sql = format!(
14278                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
14279                    );
14280                    self.conn
14281                        .execute(&index_sql, [])
14282                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14283                }
14284            }
14285        }
14286        // FTS exists only on the visible half. It is a local search
14287        // projection, never a synced/base table or a wire-schema member.
14288        for table in &self.schema.tables {
14289            for index in &table.fts_indexes {
14290                self.create_fts_projection(table, index)?;
14291            }
14292        }
14293        Ok(())
14294    }
14295
14296    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
14297        let count: i64 = self
14298            .conn
14299            .query_row(
14300                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
14301                rusqlite::params![index.name],
14302                |row| row.get(0),
14303            )
14304            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14305        Ok(count > 0)
14306    }
14307
14308    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
14309        for suffix in ["ai", "ad", "au"] {
14310            self.conn
14311                .execute(
14312                    &format!(
14313                        "DROP TRIGGER IF EXISTS {}",
14314                        quote_ident(&format!("{}_{suffix}", index.name))
14315                    ),
14316                    [],
14317                )
14318                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14319        }
14320        Ok(())
14321    }
14322
14323    fn create_fts_triggers(
14324        &self,
14325        table: &TableSchema,
14326        index: &FtsIndexSchema,
14327    ) -> Result<(), String> {
14328        let fts = quote_ident(&index.name);
14329        let source = visible_table(&table.name);
14330        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14331        let pk = quote_ident(&table.primary_key);
14332        let projection_columns = std::iter::once(source_id.clone())
14333            .chain(index.columns.iter().map(|column| quote_ident(column)))
14334            .collect::<Vec<_>>()
14335            .join(", ");
14336        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
14337            .chain(
14338                index
14339                    .columns
14340                    .iter()
14341                    .map(|column| format!("new.{}", quote_ident(column))),
14342            )
14343            .collect::<Vec<_>>()
14344            .join(", ");
14345        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14346        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)");
14347        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)");
14348        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})");
14349        let bi = quote_ident(&format!("{}_bi", index.name));
14350        let ai = quote_ident(&format!("{}_ai", index.name));
14351        let ad = quote_ident(&format!("{}_ad", index.name));
14352        let au = quote_ident(&format!("{}_au", index.name));
14353        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
14354        let sql = format!(
14355            "DROP TRIGGER IF EXISTS {bi};
14356             DROP TRIGGER IF EXISTS {ai};
14357             DROP TRIGGER IF EXISTS {ad};
14358             DROP TRIGGER IF EXISTS {au};
14359             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
14360             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
14361             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
14362             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
14363        );
14364        self.conn
14365            .execute_batch(&sql)
14366            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14367    }
14368
14369    fn rebuild_fts_projection(
14370        &self,
14371        table: &TableSchema,
14372        index: &FtsIndexSchema,
14373    ) -> Result<(), String> {
14374        let fts = quote_ident(&index.name);
14375        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14376        let pk = quote_ident(&table.primary_key);
14377        let indexed_columns = index
14378            .columns
14379            .iter()
14380            .map(|column| quote_ident(column))
14381            .collect::<Vec<_>>();
14382        let projection_columns = std::iter::once(source_id.clone())
14383            .chain(indexed_columns.iter().cloned())
14384            .collect::<Vec<_>>()
14385            .join(", ");
14386        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14387        let sql = format!(
14388            "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};",
14389            columns = indexed_columns.join(", "),
14390            source = visible_table(&table.name),
14391        );
14392        self.conn
14393            .execute_batch(&sql)
14394            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14395    }
14396
14397    fn create_fts_projection(
14398        &self,
14399        table: &TableSchema,
14400        index: &FtsIndexSchema,
14401    ) -> Result<(), String> {
14402        self.begin_observation("syncular_fts_schema")?;
14403        let result = (|| {
14404            let existed = self.fts_projection_exists(index)?;
14405            let tokenizer = index.tokenize.replace('\'', "''");
14406            let columns = index
14407                .columns
14408                .iter()
14409                .map(|column| quote_ident(column))
14410                .collect::<Vec<_>>()
14411                .join(", ");
14412            let sql = format!(
14413            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
14414            fts = quote_ident(&index.name),
14415            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
14416        );
14417            self.conn.execute(&sql, []).map_err(|error| {
14418                format!(
14419                    "cannot create local FTS5 projection {:?}: {error}",
14420                    index.name
14421                )
14422            })?;
14423            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14424            let mapped: bool = self
14425                .conn
14426                .query_row(
14427                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
14428                    [format!("_syncular_fts_{}", index.name)],
14429                    |row| row.get(0),
14430                )
14431                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14432            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))?;
14433            if !mapped && existed {
14434                self.conn
14435                    .execute_batch(&format!(
14436                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
14437                        quote_ident(FTS_SOURCE_ID_COLUMN),
14438                        quote_ident(&index.name)
14439                    ))
14440                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14441            }
14442            self.create_fts_triggers(table, index)?;
14443            if !existed {
14444                self.rebuild_fts_projection(table, index)?;
14445            }
14446            Ok(())
14447        })();
14448        if let Err(error) = result {
14449            self.rollback_observation("syncular_fts_schema");
14450            return Err(error);
14451        }
14452        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
14453            self.rollback_observation("syncular_fts_schema");
14454            return Err(error.to_string());
14455        }
14456        Ok(())
14457    }
14458
14459    // -- persistence write-through --------------------------------------------
14460
14461    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
14462        let state = serde_json::json!({
14463            "requested": scope_map_to_json(&sub.requested),
14464            "params": sub.params,
14465            "cursor": sub.cursor,
14466            "bootstrapState": sub.bootstrap_state,
14467            "status": sub.state.name(),
14468            "reasonCode": sub.reason_code,
14469            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14470            "syncedOnce": sub.synced_once,
14471        });
14472        self.conn.execute(
14473            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
14474            rusqlite::params![sub.id, sub.table, state.to_string()],
14475        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14476    }
14477
14478    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
14479        let ops: Vec<Value> = commit
14480            .ops
14481            .iter()
14482            .map(|op| {
14483                let mut entry = serde_json::json!({
14484                    "op": if op.upsert { "upsert" } else { "delete" },
14485                    "table": op.table,
14486                    "rowId": op.row_id,
14487                    "baseVersion": op.base_version,
14488                    "values": op.values.clone().map(Value::Object),
14489                });
14490                if op.insert {
14491                    entry["insert"] = Value::Bool(true);
14492                }
14493                entry
14494            })
14495            .collect();
14496        self.conn
14497            .execute(
14498                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
14499                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
14500            )
14501            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14502        for operation in &commit.ops {
14503            let Some(values) = &operation.values else {
14504                continue;
14505            };
14506            let Some(table) = self.schema.table(&operation.table) else {
14507                continue;
14508            };
14509            for column in table
14510                .columns
14511                .iter()
14512                .filter(|column| column.ty == ColumnType::BlobRef)
14513            {
14514                let Some(Value::String(raw)) = values.get(&column.name) else {
14515                    continue;
14516                };
14517                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
14518                    continue;
14519                };
14520                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
14521                    continue;
14522                };
14523                self.conn
14524                    .execute(
14525                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
14526                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
14527                        rusqlite::params![commit.client_commit_id, blob_id],
14528                    )
14529                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14530            }
14531        }
14532        Ok(())
14533    }
14534
14535    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
14536        if let Some(commit) = self
14537            .outbox
14538            .iter()
14539            .find(|commit| commit.client_commit_id == client_commit_id)
14540        {
14541            for operation in &commit.ops {
14542                self.overlay_dirty.table(&operation.table);
14543            }
14544        }
14545
14546        self.conn
14547            .execute(
14548                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
14549                rusqlite::params![client_commit_id],
14550            )
14551            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14552        self.conn
14553            .execute(
14554                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
14555                rusqlite::params![client_commit_id],
14556            )
14557            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14558        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)", [])
14559            .map(|_| ())
14560            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14561    }
14562
14563    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
14564        match status {
14565            CommitOutcomeStatus::Applied => "applied",
14566            CommitOutcomeStatus::Cached => "cached",
14567            CommitOutcomeStatus::Conflict => "conflict",
14568            CommitOutcomeStatus::Rejected => "rejected",
14569        }
14570    }
14571
14572    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
14573        match resolution {
14574            CommitOutcomeResolution::Active => "active",
14575            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
14576            CommitOutcomeResolution::Superseded => "superseded",
14577            CommitOutcomeResolution::Dismissed => "dismissed",
14578        }
14579    }
14580
14581    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
14582        match value {
14583            "applied" => Ok(CommitOutcomeStatus::Applied),
14584            "cached" => Ok(CommitOutcomeStatus::Cached),
14585            "conflict" => Ok(CommitOutcomeStatus::Conflict),
14586            "rejected" => Ok(CommitOutcomeStatus::Rejected),
14587            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
14588        }
14589    }
14590
14591    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
14592        match value {
14593            "active" => Ok(CommitOutcomeResolution::Active),
14594            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
14595            "superseded" => Ok(CommitOutcomeResolution::Superseded),
14596            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
14597            _ => Err(format!(
14598                "invalid persisted commit outcome resolution {value:?}"
14599            )),
14600        }
14601    }
14602
14603    fn persist_commit_outcome(
14604        &self,
14605        client_commit_id: &str,
14606        status: CommitOutcomeStatus,
14607        results: &[CommitOperationOutcome],
14608        operations: Option<&[OutboxOp]>,
14609    ) -> Result<(), String> {
14610        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
14611        let operations_json = operations
14612            .map(|items| {
14613                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
14614            })
14615            .transpose()
14616            .map_err(|error| error.to_string())?;
14617        self.conn
14618            .execute(
14619                "INSERT INTO _syncular_commit_outcomes (
14620                   client_commit_id, status, recorded_at_ms, results_json,
14621                   operations_json, resolution
14622                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
14623                rusqlite::params![
14624                    client_commit_id,
14625                    Self::outcome_status_name(status),
14626                    self.clock_now_ms(),
14627                    results_json,
14628                    operations_json
14629                ],
14630            )
14631            .map(|_| ())
14632            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14633    }
14634
14635    /// §7.2: the journal holds a stored cell only in its canonical shape. A cell
14636    /// the byte-envelope rule refuses cannot be journaled verbatim.
14637    fn stored_cell_is_representable(cell: &Value) -> bool {
14638        cell.is_null()
14639            || cell.is_string()
14640            || cell.is_boolean()
14641            || cell.is_number()
14642            || is_canonical_bytes_envelope(cell)
14643    }
14644
14645    fn stored_row_is_representable(value: &Value) -> bool {
14646        value
14647            .as_object()
14648            .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
14649    }
14650
14651    fn stored_operation_is_representable(value: &Value) -> bool {
14652        value.get("table").is_some_and(Value::is_string)
14653            && value.get("rowId").is_some_and(Value::is_string)
14654            && value.get("baseVersion").is_none_or(|v| {
14655                v.as_i64()
14656                    .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
14657            })
14658            && match value.get("op").and_then(Value::as_str) {
14659                Some("upsert") => {
14660                    value
14661                        .get("values")
14662                        .is_some_and(Self::stored_row_is_representable)
14663                        && value
14664                            .get("insert")
14665                            .is_none_or(|insert| *insert == Value::Bool(true))
14666                }
14667                Some("delete") => value.get("values").is_none() && value.get("insert").is_none(),
14668                _ => false,
14669            }
14670    }
14671
14672    /// §7.2/§7.4.4: a dropped commit keeps its operation envelope in the journal
14673    /// only when the journal reader accepts the serialized operation, which is the
14674    /// rule `outcome_from_row` applies to this same row later.
14675    fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
14676        serde_json::to_value(CommitOperation::from(operation))
14677            .is_ok_and(|value| Self::stored_operation_is_representable(&value))
14678    }
14679
14680    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
14681        let StoredCommitOutcomeRow {
14682            sequence,
14683            client_commit_id,
14684            status,
14685            recorded_at_ms,
14686            results_json,
14687            operations_json,
14688            resolution,
14689            resolved_at_ms,
14690            replacement_client_commit_id,
14691        } = row;
14692        if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
14693            || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
14694            || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
14695        {
14696            return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
14697        }
14698        let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
14699        let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
14700        for result in &stored {
14701            let nested = match result.get("status").and_then(Value::as_str) {
14702                Some("conflict") => result.get("conflict"),
14703                Some("error") => result.get("rejection"),
14704                _ => None,
14705            };
14706            if nested
14707                .and_then(|record| record.get("operation"))
14708                .is_some_and(|op| !Self::stored_operation_is_representable(op))
14709                || (result.get("status").and_then(Value::as_str) == Some("conflict")
14710                    && nested
14711                        .and_then(|record| record.get("serverRow"))
14712                        .is_some_and(|row| !Self::stored_row_is_representable(row)))
14713            {
14714                return Err(invalid());
14715            }
14716        }
14717        let results: Vec<CommitOperationOutcome> =
14718            serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
14719        let has_conflict = results
14720            .iter()
14721            .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
14722        let has_error = results
14723            .iter()
14724            .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
14725        let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
14726        let redacted = results.is_empty() && operations_json.is_none();
14727        if match status {
14728            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
14729            CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
14730            CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
14731        } {
14732            return Err(invalid());
14733        }
14734        let operations: Option<Vec<CommitOperation>> = operations_json
14735            .map(|raw| {
14736                let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
14737                if !stored.iter().all(Self::stored_operation_is_representable) {
14738                    return Err(invalid());
14739                }
14740                serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
14741            })
14742            .transpose()?;
14743        for result in &results {
14744            match result {
14745                CommitOperationOutcome::Conflict { conflict }
14746                    if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
14747                {
14748                    return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
14749                }
14750                CommitOperationOutcome::Error { rejection } => {
14751                    if let Some(details) = &rejection.details {
14752                        let encoded = serde_json::to_string(details).map_err(|_| {
14753                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14754                        })?;
14755                        RejectionDetails::parse(&encoded).map_err(|_| {
14756                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14757                        })?;
14758                    }
14759                }
14760                _ => {}
14761            }
14762        }
14763        Ok(CommitOutcome {
14764            sequence,
14765            client_commit_id,
14766            status,
14767            recorded_at_ms,
14768            results,
14769            operations,
14770            retained_rows: None,
14771            resolution: Self::parse_outcome_resolution(&resolution)?,
14772            resolved_at_ms,
14773            replacement_client_commit_id,
14774        })
14775    }
14776
14777    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
14778    fn unique_conflicts(
14779        &self,
14780        table: &TableSchema,
14781        values: &Map<String, Value>,
14782        base: bool,
14783    ) -> Result<Vec<RetainedUniqueConflict>, String> {
14784        let source = if base {
14785            base_table(&table.name)
14786        } else {
14787            visible_table(&table.name)
14788        };
14789        let mut conflicts = Vec::new();
14790        for index in table.indexes.iter().filter(|index| index.unique) {
14791            let mut params = vec![json_param_to_sql(
14792                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
14793            )?];
14794            for name in &index.columns {
14795                let column = table
14796                    .columns
14797                    .iter()
14798                    .find(|column| column.name == *name)
14799                    .ok_or("sync.local_corrupt")?;
14800                let cell = json_to_column_value(column, values.get(name))?;
14801                params.push(owned_sql_value(RowParam::Cell(&cell))?);
14802            }
14803            let sql = format!(
14804                "SELECT * FROM {source} WHERE {} != ? AND {}",
14805                quote_ident(&table.primary_key),
14806                index
14807                    .columns
14808                    .iter()
14809                    .map(|column| format!("{} = ?", quote_ident(column)))
14810                    .collect::<Vec<_>>()
14811                    .join(" AND ")
14812            );
14813            let mut statement = self
14814                .conn
14815                .prepare_cached(&sql)
14816                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14817            let records = statement
14818                .query_map(rusqlite::params_from_iter(params), |record| {
14819                    let values = table
14820                        .columns
14821                        .iter()
14822                        .enumerate()
14823                        .map(|(index, column)| {
14824                            let value = sql_ref_to_json(column, record.get_ref(index)?);
14825                            json_to_column_value(column, Some(&value)).map_err(|message| {
14826                                rusqlite::Error::ToSqlConversionFailure(message.into())
14827                            })
14828                        })
14829                        .collect::<Result<Row, _>>()?;
14830                    Ok((values, record.get::<_, i64>(table.columns.len())?))
14831                })
14832                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14833            for record in records {
14834                let (row, version) =
14835                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14836                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
14837                conflicts.push(RetainedUniqueConflict {
14838                    index: index.name.clone(),
14839                    columns: index.columns.clone(),
14840                    row_id,
14841                    server_row: table
14842                        .columns
14843                        .iter()
14844                        .zip(&row)
14845                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14846                        .collect(),
14847                    server_version: version,
14848                });
14849            }
14850        }
14851        Ok(conflicts)
14852    }
14853
14854    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
14855        if let Some((commit, initial)) = self
14856            .failed_commits
14857            .iter()
14858            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
14859        {
14860            let mut rows = Vec::new();
14861            for operation in &commit.ops {
14862                let table = self
14863                    .schema
14864                    .table(&operation.table)
14865                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
14866                let key =
14867                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14868                        .map_err(|_| "sync.local_corrupt".to_owned())?;
14869                let base = self.stored_row(table, &operation.row_id, true)?;
14870                rows.push(RetainedCommitRow {
14871                    table: operation.table.clone(),
14872                    row_id: operation.row_id.clone(),
14873                    unique_conflicts: if operation.upsert {
14874                        self.unique_conflicts(
14875                            table,
14876                            initial
14877                                .get(&key)
14878                                .and_then(Value::as_object)
14879                                .or(operation.values.as_ref())
14880                                .ok_or("sync.local_corrupt")?,
14881                            true,
14882                        )?
14883                    } else {
14884                        Vec::new()
14885                    },
14886                    local_row: if operation.upsert {
14887                        initial
14888                            .get(&key)
14889                            .and_then(Value::as_object)
14890                            .cloned()
14891                            .or_else(|| operation.values.clone())
14892                    } else {
14893                        None
14894                    },
14895                    server_row: base.as_ref().map(|(values, _)| {
14896                        table
14897                            .columns
14898                            .iter()
14899                            .zip(values)
14900                            .map(|(column, value)| {
14901                                (column.name.clone(), column_value_to_json(value))
14902                            })
14903                            .collect()
14904                    }),
14905                    server_version: base.map(|(_, version)| version),
14906                });
14907            }
14908            outcome.retained_rows = Some(rows);
14909        }
14910        Ok(outcome)
14911    }
14912
14913    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
14914        let row = self
14915            .conn
14916            .query_row(
14917                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14918                        resolution, resolved_at_ms, replacement_client_commit_id
14919                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
14920                rusqlite::params![client_commit_id],
14921                |row| {
14922                    Ok(StoredCommitOutcomeRow {
14923                        sequence: row.get(0)?,
14924                        client_commit_id: row.get(1)?,
14925                        status: row.get(2)?,
14926                        recorded_at_ms: row.get(3)?,
14927                        results_json: row.get(4)?,
14928                        operations_json: row.get(5)?,
14929                        resolution: row.get(6)?,
14930                        resolved_at_ms: row.get(7)?,
14931                        replacement_client_commit_id: row.get(8)?,
14932                    })
14933                },
14934            )
14935            .optional()
14936            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14937        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
14938            .transpose()
14939    }
14940
14941    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
14942        if query.limit == Some(0) {
14943            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
14944        }
14945        let mut sql = String::from(
14946            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14947                    resolution, resolved_at_ms, replacement_client_commit_id
14948               FROM _syncular_commit_outcomes",
14949        );
14950        if query.active_only {
14951            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
14952        }
14953        sql.push_str(" ORDER BY seq DESC");
14954        if let Some(limit) = query.limit {
14955            sql.push_str(&format!(" LIMIT {limit}"));
14956        }
14957        let mut stmt = self
14958            .conn
14959            .prepare(&sql)
14960            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14961        let rows = stmt
14962            .query_map([], |row| {
14963                Ok(StoredCommitOutcomeRow {
14964                    sequence: row.get(0)?,
14965                    client_commit_id: row.get(1)?,
14966                    status: row.get(2)?,
14967                    recorded_at_ms: row.get(3)?,
14968                    results_json: row.get(4)?,
14969                    operations_json: row.get(5)?,
14970                    resolution: row.get(6)?,
14971                    resolved_at_ms: row.get(7)?,
14972                    replacement_client_commit_id: row.get(8)?,
14973                })
14974            })
14975            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14976        let mut outcomes = Vec::new();
14977        for row in rows {
14978            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
14979                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
14980            )?)?);
14981        }
14982        Ok(outcomes)
14983    }
14984
14985    fn prune_commit_outcomes(&self) -> Result<(), String> {
14986        #[cfg(test)]
14987        self.outcome_prune_count
14988            .set(self.outcome_prune_count.get() + 1);
14989        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
14990        let count = self
14991            .conn
14992            .query_row(
14993                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
14994                [],
14995                |row| row.get::<_, i64>(0),
14996            )
14997            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14998            as usize;
14999        let excess = count.saturating_sub(max_entries);
15000        if excess == 0 {
15001            return Ok(());
15002        }
15003        let mut stmt = self
15004            .conn
15005            .prepare(
15006                "SELECT seq FROM _syncular_commit_outcomes
15007                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
15008                  ORDER BY seq ASC LIMIT ?1",
15009            )
15010            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15011        let rows = stmt
15012            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
15013            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15014        let sequences = rows
15015            .collect::<Result<Vec<_>, _>>()
15016            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15017        drop(stmt);
15018        for sequence in sequences {
15019            self.conn
15020                .execute(
15021                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
15022                    rusqlite::params![sequence],
15023                )
15024                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15025        }
15026        Ok(())
15027    }
15028
15029    // -- driver surface ---------------------------------------------------------
15030
15031    pub fn subscribe(
15032        &mut self,
15033        id: String,
15034        table: String,
15035        scopes: Vec<(String, Vec<String>)>,
15036        params: Option<String>,
15037    ) -> Result<(), String> {
15038        if self.schema.table(&table).is_none() {
15039            return Err(format!("unknown table {table:?}"));
15040        }
15041        let registered_at = self
15042            .subs
15043            .iter()
15044            .position(|subscription| subscription.id == id);
15045        if let Some(index) = registered_at {
15046            let existing = &self.subs[index];
15047            let same_intent = existing.table == table
15048                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
15049                && existing.params == params;
15050            if !same_intent {
15051                return Err(
15052                    "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
15053                        .to_owned(),
15054                );
15055            }
15056            // §3.3: a revoked registration keeps its id and intent but no
15057            // progress. Subscribing again with the same intent re-arms it and
15058            // asks for a pull, which resolves its scopes from scratch. A
15059            // `failed` registration is a configuration error and stays put.
15060            if existing.state != SubState::Revoked {
15061                return Ok(());
15062            }
15063        }
15064        let sub = Subscription {
15065            id: id.clone(),
15066            table,
15067            requested: scopes,
15068            params,
15069            cursor: -1,
15070            bootstrap_state: None,
15071            state: SubState::Active,
15072            reason_code: None,
15073            effective: None,
15074            synced_once: false,
15075        };
15076        self.persist_sub(&sub)?;
15077        match registered_at {
15078            Some(index) => self.subs[index] = sub,
15079            None => self.subs.push(sub),
15080        }
15081        // Only a re-arm asks for a sync here: a fresh registration reaches the
15082        // next round through the host's schedule, and `set_window` (§4.8)
15083        // batches its own intent for the units it registers.
15084        if registered_at.is_some() {
15085            self.set_sync_needed(true, true);
15086        }
15087        Ok(())
15088    }
15089
15090    /// Register several subscriptions in one local transaction: the same
15091    /// rules as `subscribe` for each request, and one commit for all of them
15092    /// (on OPFS a commit costs milliseconds, so a cold start with hundreds of
15093    /// registrations pays it once). An error rolls back every registration
15094    /// of the call.
15095    pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
15096        let prior_subs = self.subs.clone();
15097        self.begin_observation("syncular_subscribe")?;
15098        let registered = (|| {
15099            for request in requests {
15100                self.subscribe(request.id, request.table, request.scopes, request.params)?;
15101            }
15102            self.conn
15103                .execute_batch("RELEASE syncular_subscribe")
15104                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15105        })();
15106        if let Err(error) = registered {
15107            self.rollback_observation("syncular_subscribe");
15108            self.subs = prior_subs;
15109            return Err(error);
15110        }
15111        Ok(())
15112    }
15113
15114    pub fn unsubscribe(&mut self, id: &str) {
15115        if self.subs.iter().any(|s| s.id == id) {
15116            self.cancel_sync_round();
15117        }
15118        self.subs.retain(|s| s.id != id);
15119        let _ = self.conn.execute(
15120            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15121            rusqlite::params![id],
15122        );
15123    }
15124
15125    // -- windowed subscriptions (§4.8) ------------------------------------------
15126
15127    /// §4.8: set the live window units for a base — a value-sharded family
15128    /// of subscriptions, one per unit. Added units get fresh subscriptions
15129    /// (image-lane bootstrap on the next sync); removed units are
15130    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
15131    /// Idempotent; re-entry cancels any deferred eviction.
15132    pub fn set_window(
15133        &mut self,
15134        base: &WindowBase,
15135        units: &[String],
15136    ) -> Result<CommandEffects, String> {
15137        let table = self
15138            .schema
15139            .table(&base.table)
15140            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
15141        if table.scope_column(&base.variable).is_none() {
15142            return Err(format!(
15143                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
15144                base.table, base.variable
15145            ));
15146        }
15147        let base_key = window_base_key(base);
15148        let wanted: std::collections::HashSet<&String> = units.iter().collect();
15149        let live = self.load_window_units(&base_key);
15150        self.begin_observation("syncular_window")?;
15151        let mut batch = ChangeAccumulator::default();
15152        let mut changed = false;
15153
15154        let prior_subs = self.subs.clone();
15155        let widened = (|| {
15156            // Widen: units wanted but not live → fresh subscription + registry row.
15157            for unit in units {
15158                if live.iter().any(|(u, _)| u == unit) {
15159                    continue;
15160                }
15161                let sub_id = derive_sub_id(base, unit);
15162                self.delete_pending_evict(&sub_id)?;
15163                self.insert_window_unit(&base_key, unit, &sub_id)?;
15164                self.subscribe(
15165                    sub_id,
15166                    base.table.clone(),
15167                    unit_scopes(base, unit),
15168                    base.params.clone(),
15169                )?;
15170                batch.window(&base_key, &base.table, unit);
15171                changed = true;
15172            }
15173
15174            self.finish_observation("syncular_window", batch)
15175        })();
15176        if let Err(error) = widened {
15177            self.rollback_observation("syncular_window");
15178            self.subs = prior_subs;
15179            return Err(error);
15180        }
15181        for (unit, sub_id) in live {
15182            if wanted.contains(&unit) {
15183                continue;
15184            }
15185            self.rebuild_overlay_if_dirty()?;
15186            let prior_subs = self.subs.clone();
15187            self.begin_observation("syncular_window")?;
15188            let effective = self
15189                .subs
15190                .iter()
15191                .find(|sub| sub.id == sub_id)
15192                .and_then(|sub| sub.effective.clone())
15193                .unwrap_or_else(|| unit_scopes(base, &unit));
15194            let mut batch = ChangeAccumulator::default();
15195            self.record_scope_map(&mut batch, &base.table, &effective);
15196            batch.window(&base_key, &base.table, &unit);
15197            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
15198            let remaining = match result {
15199                Ok(remaining) => remaining,
15200                Err(error) => {
15201                    self.rollback_observation("syncular_window");
15202                    self.subs = prior_subs;
15203                    return Err(error);
15204                }
15205            };
15206            if let Err(error) = self.finish_observation("syncular_window", batch) {
15207                self.rollback_observation("syncular_window");
15208                self.subs = prior_subs;
15209                return Err(error);
15210            }
15211            if remaining {
15212                self.drain_pending_evictions()?;
15213            }
15214            changed = true;
15215        }
15216        Ok(if changed {
15217            CommandEffects::interactive()
15218        } else {
15219            CommandEffects::none()
15220        })
15221    }
15222
15223    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
15224    /// the subset still bootstrap-pending. Registration alone is not
15225    /// completeness — a unit is pending until its subscription completes a
15226    /// bootstrap round (cursor advances past -1 with no resume token held).
15227    pub fn window_state(&self, base: &WindowBase) -> WindowState {
15228        let mut units = Vec::new();
15229        let mut pending = Vec::new();
15230        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
15231            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
15232                Some(sub) => {
15233                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
15234                }
15235                None => true,
15236            };
15237            if is_pending {
15238                pending.push(unit.clone());
15239            }
15240            units.push(unit);
15241        }
15242        WindowState { units, pending }
15243    }
15244
15245    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
15246        let mut stmt = match self
15247            .conn
15248            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
15249        {
15250            Ok(stmt) => stmt,
15251            Err(_) => return Vec::new(),
15252        };
15253        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
15254            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
15255        });
15256        match rows {
15257            Ok(rows) => rows.filter_map(Result::ok).collect(),
15258            Err(_) => Vec::new(),
15259        }
15260    }
15261
15262    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
15263        let mut stmt = match self.conn.prepare(
15264            "SELECT windows.base, windows.unit, subscriptions.tbl
15265               FROM _syncular_windows AS windows
15266               JOIN _syncular_subscriptions AS subscriptions
15267                 ON subscriptions.id = windows.sub_id
15268               ORDER BY windows.base, windows.unit",
15269        ) {
15270            Ok(stmt) => stmt,
15271            Err(_) => return Vec::new(),
15272        };
15273        let rows = stmt.query_map([], |row| {
15274            Ok((
15275                row.get::<_, String>(0)?,
15276                row.get::<_, String>(1)?,
15277                row.get::<_, String>(2)?,
15278            ))
15279        });
15280        match rows {
15281            Ok(rows) => rows.filter_map(Result::ok).collect(),
15282            Err(_) => Vec::new(),
15283        }
15284    }
15285
15286    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
15287        self.conn
15288            .query_row(
15289                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
15290                rusqlite::params![sub_id],
15291                |row| Ok((row.get(0)?, row.get(1)?)),
15292            )
15293            .ok()
15294    }
15295
15296    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
15297        self.conn
15298            .execute(
15299                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
15300                rusqlite::params![base_key, unit, sub_id],
15301            )
15302            .map(|_| ())
15303            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15304    }
15305
15306    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
15307        self.conn
15308            .execute(
15309                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
15310                rusqlite::params![base_key, unit],
15311            )
15312            .map(|_| ())
15313            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15314    }
15315
15316    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
15317    /// Deletes the unit's rows except those pinned by a pending outbox
15318    /// commit (E1); records a deferred eviction if any pin remains; discards
15319    /// the subscription's cursor/resume/effective-echo (E3) and version
15320    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
15321    /// nothing.
15322    fn evict_unit(
15323        &mut self,
15324        base_key: &str,
15325        base: &WindowBase,
15326        unit: &str,
15327        sub_id: &str,
15328    ) -> Result<bool, String> {
15329        let effective = self
15330            .subs
15331            .iter()
15332            .find(|s| s.id == sub_id)
15333            .and_then(|s| s.effective.clone())
15334            .unwrap_or_else(|| unit_scopes(base, unit));
15335        let pinned = self.pinned_row_ids(&base.table);
15336        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
15337        self.delete_window_unit(base_key, unit)?;
15338        self.conn
15339            .execute(
15340                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15341                [sub_id],
15342            )
15343            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15344        self.subs.retain(|sub| sub.id != sub_id);
15345        if deferred {
15346            self.save_pending_evict(sub_id, &base.table, &effective)?;
15347        } else {
15348            self.delete_pending_evict(sub_id)?;
15349        }
15350        Ok(remaining)
15351    }
15352
15353    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
15354    /// primary keys. Returns (more unpinned work, any scoped rows remain).
15355    /// A missing scope-column mapping fails without deleting rows.
15356    fn evict_scope_rows(
15357        &self,
15358        table_name: &str,
15359        effective: &[(String, Vec<String>)],
15360        pinned: &std::collections::HashSet<String>,
15361    ) -> Result<(bool, bool), String> {
15362        if effective.is_empty() {
15363            return Ok((false, false));
15364        }
15365        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
15366        let mut clauses = Vec::new();
15367        let mut params: Vec<SqlValue> = Vec::new();
15368        for (variable, values) in effective {
15369            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
15370            if values.is_empty() {
15371                return Ok((false, false));
15372            }
15373            let holes = values
15374                .iter()
15375                .map(|v| {
15376                    params.push(SqlValue::Text(v.clone()));
15377                    "?"
15378                })
15379                .collect::<Vec<_>>()
15380                .join(", ");
15381            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
15382        }
15383        let scope = clauses.join(" AND ");
15384        let scope_params = params.len();
15385        let pk = quote_ident(&table.primary_key);
15386        if !pinned.is_empty() {
15387            let holes = pinned
15388                .iter()
15389                .map(|id| {
15390                    params.push(SqlValue::Text(id.clone()));
15391                    "?"
15392                })
15393                .collect::<Vec<_>>()
15394                .join(", ");
15395            clauses.push(format!("{pk} NOT IN ({holes})"));
15396        }
15397        let predicate = clauses.join(" AND ");
15398        let base = base_table(table_name);
15399        let visible = visible_table(table_name);
15400        let mut ids: Vec<SqlValue> = Vec::new();
15401        for source in [&base, &visible] {
15402            let mut stmt = self
15403                .conn
15404                .prepare_cached(&format!(
15405                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
15406                ))
15407                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15408            ids = stmt
15409                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
15410                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15411                .collect::<Result<_, _>>()
15412                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15413            if !ids.is_empty() {
15414                break;
15415            }
15416        }
15417        if !ids.is_empty() {
15418            for id in &ids {
15419                self.conn
15420                    .execute(
15421                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
15422                        rusqlite::params![table_name, id],
15423                    )
15424                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15425            }
15426            let holes = vec!["?"; ids.len()].join(", ");
15427            for source in [&base, &visible] {
15428                self.conn
15429                    .execute(
15430                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
15431                        rusqlite::params_from_iter(&ids),
15432                    )
15433                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15434            }
15435        }
15436        let mut remaining = false;
15437        let mut deferred = false;
15438        for source in [&base, &visible] {
15439            remaining |= self
15440                .conn
15441                .query_row(
15442                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
15443                    rusqlite::params_from_iter(&params),
15444                    |row| row.get::<_, bool>(0),
15445                )
15446                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15447            deferred |= self
15448                .conn
15449                .query_row(
15450                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
15451                    rusqlite::params_from_iter(&params[..scope_params]),
15452                    |row| row.get::<_, bool>(0),
15453                )
15454                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15455        }
15456        Ok((remaining, deferred))
15457    }
15458
15459    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
15460    fn drain_pending_evictions(&mut self) -> Result<(), String> {
15461        let pending = self.load_pending_evictions()?;
15462        if pending.is_empty() {
15463            return Ok(());
15464        }
15465        self.rebuild_overlay_if_dirty()?;
15466        for (sub_id, table_name, effective) in pending {
15467            if self.schema.table(&table_name).is_none() {
15468                self.delete_pending_evict(&sub_id)?;
15469                continue;
15470            }
15471            loop {
15472                std::thread::yield_now();
15473                self.begin_observation("syncular_evict")?;
15474                let pinned = self.pinned_row_ids(&table_name);
15475                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
15476                let (remaining, deferred) = match result {
15477                    Ok(result) => result,
15478                    Err(error) => {
15479                        self.rollback_observation("syncular_evict");
15480                        return Err(error);
15481                    }
15482                };
15483                if !deferred {
15484                    if let Err(error) = self.delete_pending_evict(&sub_id) {
15485                        self.rollback_observation("syncular_evict");
15486                        return Err(error);
15487                    }
15488                }
15489                let mut batch = ChangeAccumulator::default();
15490                self.record_scope_map(&mut batch, &table_name, &effective);
15491                if let Err(error) = self.finish_observation("syncular_evict", batch) {
15492                    self.rollback_observation("syncular_evict");
15493                    return Err(error);
15494                }
15495                if !remaining {
15496                    break;
15497                }
15498            }
15499        }
15500        Ok(())
15501    }
15502
15503    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
15504    /// commit — rows that MUST NOT be evicted until the commit drains.
15505    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
15506        let mut pinned = std::collections::HashSet::new();
15507        for commit in self
15508            .outbox
15509            .iter()
15510            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
15511        {
15512            for op in &commit.ops {
15513                if op.table == table {
15514                    pinned.insert(op.row_id.clone());
15515                }
15516            }
15517        }
15518        pinned
15519    }
15520
15521    fn save_pending_evict(
15522        &self,
15523        sub_id: &str,
15524        table: &str,
15525        effective: &[(String, Vec<String>)],
15526    ) -> Result<(), String> {
15527        self.conn.execute(
15528            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
15529            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
15530        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15531    }
15532
15533    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
15534        self.conn
15535            .execute(
15536                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
15537                rusqlite::params![sub_id],
15538            )
15539            .map(|_| ())
15540            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15541    }
15542
15543    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
15544        let mut stmt = self
15545            .conn
15546            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
15547            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15548        let rows = stmt
15549            .query_map([], |row| {
15550                Ok((
15551                    row.get::<_, String>(0)?,
15552                    row.get::<_, String>(1)?,
15553                    row.get::<_, String>(2)?,
15554                ))
15555            })
15556            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15557        rows.map(|row| {
15558            let (sub_id, table, json) =
15559                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15560            let value: Value = serde_json::from_str(&json)
15561                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15562            let effective = json_to_scope_map(&value)
15563                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15564            Ok((sub_id, table, effective))
15565        })
15566        .collect()
15567    }
15568
15569    /// Select failed-intent retention; reload durable retained aggregates.
15570    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
15571        self.retain_failed_commits = enabled;
15572        for (commit, _) in &self.failed_commits {
15573            for operation in &commit.ops {
15574                self.overlay_dirty.table(&operation.table);
15575            }
15576        }
15577        self.failed_commits = self.load_failed_commits()?;
15578        for (commit, _) in &self.failed_commits {
15579            for operation in &commit.ops {
15580                self.overlay_dirty.table(&operation.table);
15581            }
15582        }
15583        Ok(())
15584    }
15585
15586    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
15587        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
15588            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15589        let rows = statement
15590            .query_map([], |row| {
15591                Ok((
15592                    row.get::<_, String>(0)?,
15593                    row.get::<_, String>(1)?,
15594                    row.get::<_, String>(2)?,
15595                ))
15596            })
15597            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15598        rows.map(|row| {
15599            let (id, operations, initial) =
15600                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15601            let operations: Vec<CommitOperation> =
15602                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
15603            let initial =
15604                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
15605            Ok((
15606                OutboxCommit {
15607                    client_commit_id: id,
15608                    ops: operations
15609                        .into_iter()
15610                        .map(|op| OutboxOp {
15611                            upsert: op.op == "upsert",
15612                            table: op.table,
15613                            row_id: op.row_id,
15614                            base_version: op.base_version,
15615                            values: op.values,
15616                            insert: op.insert,
15617                        })
15618                        .collect(),
15619                },
15620                initial,
15621            ))
15622        })
15623        .collect()
15624    }
15625
15626    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
15627        let mut initial = Map::new();
15628        for operation in operations {
15629            let table = self
15630                .schema
15631                .table(&operation.table)
15632                .ok_or_else(|| "sync.unknown_table".to_owned())?;
15633            let initial_row = match self.stored_row(table, &operation.row_id, true)? {
15634                Some(row) => Some(row),
15635                None => self.visible_row(table, &operation.row_id)?,
15636            };
15637            if let Some((values, _)) = initial_row {
15638                let mut intended: Map<String, Value> = table
15639                    .columns
15640                    .iter()
15641                    .zip(values.iter())
15642                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
15643                    .collect();
15644                if let Some(patch) = &operation.values {
15645                    intended.extend(patch.clone());
15646                }
15647                initial.insert(
15648                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15649                        .map_err(|_| "sync.local_corrupt".to_owned())?,
15650                    Value::Object(intended),
15651                );
15652            }
15653        }
15654        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
15655            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
15656            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
15657            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15658        self.failed_commits.push((
15659            OutboxCommit {
15660                client_commit_id: id.to_owned(),
15661                ops: operations.to_vec(),
15662            },
15663            initial,
15664        ));
15665        Ok(())
15666    }
15667
15668    fn drop_failed_commits(
15669        &mut self,
15670        matches: impl Fn(&str, &Map<String, Value>) -> bool,
15671    ) -> Result<Vec<OutboxCommit>, String> {
15672        let mut doomed = self.drop_acknowledged_rows(&matches)?;
15673        for (commit, initial) in &self.failed_commits {
15674            for operation in &commit.ops {
15675                let key =
15676                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15677                        .map_err(|_| "sync.local_corrupt".to_owned())?;
15678                if initial
15679                    .get(&key)
15680                    .and_then(Value::as_object)
15681                    .is_some_and(|values| matches(&operation.table, values))
15682                {
15683                    doomed.push(commit.clone());
15684                    break;
15685                }
15686            }
15687        }
15688        for commit in &doomed {
15689            self.conn
15690                .execute(
15691                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15692                    rusqlite::params![commit.client_commit_id],
15693                )
15694                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15695        }
15696        for commit in &doomed {
15697            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))?;
15698        }
15699        self.conflicts.retain(|record| {
15700            !doomed
15701                .iter()
15702                .any(|commit| commit.client_commit_id == record.client_commit_id)
15703        });
15704        self.rejections.retain(|record| {
15705            !doomed
15706                .iter()
15707                .any(|commit| commit.client_commit_id == record.client_commit_id)
15708        });
15709        self.failed_commits.retain(|(commit, _)| {
15710            !doomed
15711                .iter()
15712                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
15713        });
15714        for commit in &doomed {
15715            for operation in &commit.ops {
15716                self.overlay_dirty.table(&operation.table);
15717            }
15718        }
15719        Ok(doomed)
15720    }
15721
15722    /// Record one commit of local mutations. The returned commit ID is the
15723    /// §2.3 idempotency key. Failures are structured so callers read
15724    /// `code`/`message`/`details` instead of parsing a string prefix.
15725    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
15726        let prior = self.storage_failure.borrow_mut().take();
15727        let outcome = (|| -> Result<String, String> {
15728            if mutations.is_empty() {
15729                return Err(
15730                    "sync.invalid_request: a commit must contain at least one operation (§6.1)"
15731                        .to_owned(),
15732                );
15733            }
15734            let mut ops = Vec::with_capacity(mutations.len());
15735            for mutation in mutations {
15736                match mutation {
15737                    Mutation::Patch {
15738                        table,
15739                        values,
15740                        base_version,
15741                    } => {
15742                        let schema_table = self
15743                            .schema
15744                            .table(&table)
15745                            .ok_or_else(|| "sync.unknown_table".to_owned())?;
15746                        let values = normalize_values_casing(schema_table, values)?;
15747                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15748                        ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
15749                    }
15750                    Mutation::Upsert {
15751                        table,
15752                        values,
15753                        base_version,
15754                    } => {
15755                        let schema_table = self.schema.table(&table).ok_or_else(|| {
15756                            format!("sync.unknown_table: unknown table {table:?}")
15757                        })?;
15758                        // §5: value keys are accepted in snake_case AND the
15759                        // generated row types' camelCase; normalize to SQL truth
15760                        // before the pk lookup / codec see them.
15761                        let values = normalize_values_casing(schema_table, values)?;
15762                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15763                        // §6.7 `mutate` marks every column present: fill missing
15764                        // nullable columns with NULL before the payload is built.
15765                        let values = full_row_values(schema_table, values)?;
15766                        // §5.11: validate the payload encodes with the current
15767                        // codec. A key-selection or unknown-key failure is a
15768                        // durable rejection at the push seam (§10.3), never an
15769                        // author-time error. Full-row `mutate` presents every
15770                        // column, so no stored fallback is needed.
15771                        self.validate_author_encode(schema_table, &row_id, &values)?;
15772                        // §7.1 insert intent: the row is absent from the
15773                        // visible replica at call time, before this batch
15774                        // applies anything.
15775                        let insert = !self.visible_row_exists(schema_table, &row_id)?;
15776                        ops.push(OutboxOp {
15777                            upsert: true,
15778                            table,
15779                            row_id,
15780                            base_version,
15781                            values: Some(values),
15782                            insert,
15783                        });
15784                    }
15785                    Mutation::Delete {
15786                        table,
15787                        row_id,
15788                        base_version,
15789                    } => {
15790                        if self.schema.table(&table).is_none() {
15791                            return Err(format!("sync.unknown_table: unknown table {table:?}"));
15792                        }
15793                        ops.push(OutboxOp {
15794                            upsert: false,
15795                            table,
15796                            row_id,
15797                            base_version,
15798                            values: None,
15799                            insert: false,
15800                        });
15801                    }
15802                }
15803            }
15804            self.record_outbox_commit(ops)
15805        })();
15806        self.finish_authoring(prior, outcome)
15807    }
15808
15809    /// Scope the retained `storage_failure` diagnostic to this call so an
15810    /// earlier operation's failure cannot classify a new one, and surface the
15811    /// call's own classified failure with its details. A fresh classified
15812    /// failure replaces the retained diagnostic; otherwise the earlier one is
15813    /// kept for the next sync.
15814    fn finish_authoring(
15815        &self,
15816        prior: Option<QueryReadFailure>,
15817        outcome: Result<String, String>,
15818    ) -> Result<String, ClientError> {
15819        let fresh = self.storage_failure.borrow_mut().take();
15820        *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
15821        match outcome {
15822            Ok(id) => Ok(id),
15823            Err(message) => Err(fresh
15824                .map(ClientError::from)
15825                .unwrap_or_else(|| ClientError::from(message))),
15826        }
15827    }
15828
15829    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
15830        let commit = OutboxCommit {
15831            client_commit_id: uuid::Uuid::new_v4().to_string(),
15832            ops,
15833        };
15834        self.begin_observation("syncular_mutation")?;
15835        let mut batch = ChangeAccumulator::default();
15836        for op in &commit.ops {
15837            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
15838            if let Some(values) = &op.values {
15839                if let Some(table) = self.schema.table(&op.table) {
15840                    for scope in &table.scope_variables {
15841                        if let Some(Value::String(value)) = values.get(&scope.column) {
15842                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
15843                            precise = true;
15844                        }
15845                    }
15846                }
15847            }
15848            if !precise {
15849                batch.table(&op.table);
15850            }
15851        }
15852        if let Err(error) = self.persist_outbox_insert(&commit) {
15853            self.rollback_observation("syncular_mutation");
15854            return Err(error);
15855        }
15856        let was_dirty = self.overlay_dirty.snapshot();
15857        // A clean overlay contains the FIFO fold of every previous commit, so
15858        // the new commit applies over it. §7.1: an operation that does not
15859        // apply (a secondary unique collision) fails the whole commit; the
15860        // savepoint rollback removes the outbox entry and every visible write.
15861        if let Err(error) = self.rebuild_overlay_if_dirty() {
15862            self.rollback_observation("syncular_mutation");
15863            self.overlay_dirty.restore(was_dirty.clone());
15864            return Err(error);
15865        }
15866        if let Some(error) = commit
15867            .ops
15868            .iter()
15869            .find_map(|op| self.apply_outbox_op(op).err())
15870        {
15871            self.rollback_observation("syncular_mutation");
15872            self.overlay_dirty.restore(was_dirty.clone());
15873            return Err(error);
15874        }
15875        let id = commit.client_commit_id.clone();
15876        self.outbox.push(commit);
15877        batch.status = true;
15878        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
15879            self.rollback_observation("syncular_mutation");
15880            self.outbox.pop();
15881            self.overlay_dirty.restore(was_dirty.clone());
15882            return Err(error);
15883        }
15884        Ok(id)
15885    }
15886
15887    /// Record one §6.1 sparse upsert: the primary key plus the supplied
15888    /// non-scope columns are present; every other column is absent and stays
15889    /// untouched on the server and in the local overlay. An absent local base
15890    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
15891    /// Failures are structured so callers read `code`/`message`/`details`.
15892    pub fn patch(
15893        &mut self,
15894        table: &str,
15895        row_id: &str,
15896        partial: Map<String, Value>,
15897        base_version: Option<i64>,
15898    ) -> Result<String, ClientError> {
15899        let prior = self.storage_failure.borrow_mut().take();
15900        let outcome = self.perform_patch(table, row_id, partial, base_version);
15901        self.finish_authoring(prior, outcome)
15902    }
15903
15904    /// The unscoped patch body. Public authoring entry points scope
15905    /// `storage_failure` around the whole operation, including any pre-read.
15906    fn perform_patch(
15907        &mut self,
15908        table: &str,
15909        row_id: &str,
15910        partial: Map<String, Value>,
15911        base_version: Option<i64>,
15912    ) -> Result<String, String> {
15913        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
15914        self.record_outbox_commit(vec![operation])
15915    }
15916
15917    fn prepare_patch(
15918        &self,
15919        table: &str,
15920        row_id: &str,
15921        partial: Map<String, Value>,
15922        base_version: Option<i64>,
15923    ) -> Result<OutboxOp, String> {
15924        let schema_table = self
15925            .schema
15926            .table(table)
15927            .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
15928        let partial = normalize_values_casing(schema_table, partial)?;
15929        if let Some(pk) = partial.get(&schema_table.primary_key) {
15930            if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
15931                return Err(format!(
15932                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
15933                ));
15934            }
15935        }
15936        if !self.visible_row_exists(schema_table, row_id)? {
15937            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
15938        }
15939        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
15940        // server accepts a present scope column whose value equals the
15941        // stored row and applies it as a no-op, so a decoded envelope
15942        // round-trips. The local row is the only value the client can prove
15943        // equality against; when the row is absent locally the client cannot
15944        // prove equality against the server's stored row and fails closed.
15945        // A primary key that is also a scope column needs no stored-row
15946        // read: the primary key in a sparse payload is by construction the
15947        // row id being patched (§6.1), so its value is proven equal already.
15948        for scope in &schema_table.scope_variables {
15949            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
15950                continue;
15951            }
15952            let Some(column) = schema_table
15953                .columns
15954                .iter()
15955                .find(|column| column.name == scope.column)
15956            else {
15957                continue;
15958            };
15959            let stored = self
15960                .conn
15961                .query_row(
15962                    &format!(
15963                        "SELECT {} FROM {} WHERE {}",
15964                        quote_ident(&scope.column),
15965                        visible_table(&schema_table.name),
15966                        row_id_predicate(schema_table)
15967                    ),
15968                    rusqlite::params![row_id],
15969                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
15970                )
15971                .optional()
15972                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15973            // Decode the supplied value through the same column-type path the
15974            // rest of the patch encoding uses, so a value that only equals
15975            // the stored one in another number representation still compares
15976            // equal.
15977            let incoming = json_to_column_value(column, partial.get(&scope.column))
15978                .ok()
15979                .map(|value| column_value_to_json(&value));
15980            // A stored blob never equals a patch value, matching the server's
15981            // scope-column comparison (§3.4): bytes are unstorable as scope
15982            // values.
15983            let equal = stored.as_ref().is_some_and(|stored| {
15984                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
15985            });
15986            if !equal {
15987                return Err(format!(
15988                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
15989                    scope.column
15990                ));
15991            }
15992        }
15993        let mut values = partial;
15994        // Presence-set semantics: a proven-equal scope column is dropped, so
15995        // the patch leaves the stored value untouched (§6.2).
15996        for scope in &schema_table.scope_variables {
15997            values.remove(&scope.column);
15998        }
15999        let pk_value = match schema_table.columns[schema_table.pk_index].ty {
16000            // §2.4: the rowId of an integer key is its canonical decimal form.
16001            ColumnType::Integer => match row_id.parse::<i64>() {
16002                Ok(v)
16003                    if v.to_string() == row_id
16004                        && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
16005                            .contains(&v) =>
16006                {
16007                    Value::from(v)
16008                }
16009                _ => {
16010                    return Err(format!(
16011                    "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
16012                ))
16013                }
16014            },
16015            ColumnType::Boolean => match row_id {
16016                "true" => Value::Bool(true),
16017                "false" => Value::Bool(false),
16018                _ => {
16019                    return Err(format!(
16020                    "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
16021                ))
16022                }
16023            },
16024            _ => Value::from(row_id.to_owned()),
16025        };
16026        values.insert(schema_table.primary_key.clone(), pk_value);
16027        // §5.11: structural validation only; an absent key-id selector is
16028        // resolved at the push seam from the stored local row, and a
16029        // key-selection or unknown-key failure becomes a durable
16030        // `client.encrypt_failed` rejection there (§10.3), never an
16031        // author-time error.
16032        self.validate_author_encode(schema_table, row_id, &values)?;
16033        Ok(OutboxOp {
16034            upsert: true,
16035            table: table.to_owned(),
16036            row_id: row_id.to_owned(),
16037            base_version,
16038            values: Some(values),
16039            insert: false,
16040        })
16041    }
16042
16043    /// §5.11 key-id fallback: the stored local row's selector value as a
16044    /// positional row (only the selector slot filled). Returns None when no
16045    /// selector is configured, the row is locally absent, or the stored
16046    /// value is not a usable key id.
16047    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
16048        let selector = self.encryption.key_id_columns.get(&table.name)?;
16049        let index = table
16050            .columns
16051            .iter()
16052            .position(|column| &column.name == selector)?;
16053        let sql = format!(
16054            "SELECT {} FROM {} WHERE {}",
16055            quote_ident(selector),
16056            visible_table(&table.name),
16057            row_id_predicate(table)
16058        );
16059        let key: Option<String> = self
16060            .conn
16061            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
16062            .ok()?;
16063        let key = key.filter(|key| !key.is_empty())?;
16064        let mut fallback: Row = vec![None; table.columns.len()];
16065        fallback[index] = Some(ColumnValue::String(key));
16066        Some(fallback)
16067    }
16068
16069    /// §5.11/§10.3 author-time encode check: a structural failure (a
16070    /// non-nullable NULL, a value the codec rejects) is an author-time
16071    /// error; a key-selection or unknown-key failure is deferred to the push
16072    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
16073    fn validate_author_encode(
16074        &self,
16075        table: &TableSchema,
16076        row_id: &str,
16077        values: &Map<String, Value>,
16078    ) -> Result<(), String> {
16079        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
16080            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
16081            result => result.map(|_| ()),
16082        }
16083    }
16084
16085    /// §5.11/§10.3: drop every pending commit whose push encode fails with
16086    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
16087    /// raise one durable rejection per commit. `sync()` never aborts for an
16088    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
16089    /// undoing the dropped commit's purely-optimistic rows.
16090    /// §7.1/§7.4.4: drop pending commits whose values the current codec refuses
16091    /// (an envelope or a type an earlier version accepted). `with_encryption`
16092    /// additionally drops commits whose §5.11 encryption cannot resolve a key;
16093    /// the startup boundary leaves those to the send seam, where hosts configure
16094    /// keys after open.
16095    /// Recovery runs as one observation. An enclosing observation (the guarded
16096    /// schema reset) passes its `batch`, so the rejection, outcome, and table
16097    /// domains join that change event instead of publishing a second one.
16098    fn drop_unencodable_outbox(
16099        &mut self,
16100        with_encryption: bool,
16101        batch: Option<&mut ChangeAccumulator>,
16102    ) -> Result<bool, String> {
16103        let failures = self
16104            .outbox
16105            .iter()
16106            .filter_map(|commit| {
16107                commit
16108                    .ops
16109                    .iter()
16110                    .find_map(|op| {
16111                        let table = self.schema.table(&op.table).filter(|_| op.upsert)?;
16112                        // An insert lacking a non-nullable column is left to
16113                        // `drop_incompatible_outbox` (§7.4.4).
16114                        let values = op.presented_values(table)?;
16115                        let fallback = self.stored_key_fallback(table, &op.row_id);
16116                        match encode_sparse_row_json(
16117                            table,
16118                            &op.row_id,
16119                            &values,
16120                            &self.encryption,
16121                            fallback.as_ref(),
16122                        ) {
16123                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
16124                                with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
16125                            }
16126                            // §7.1/§7.4.4: a value the current codec refuses was
16127                            // persisted by a version that accepted it; it leaves
16128                            // through the incompatible-outbox path, so the FIFO
16129                            // queue never wedges.
16130                            Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
16131                                LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
16132                                OUTBOX_INCOMPATIBLE_CODE,
16133                            )),
16134                            _ => None,
16135                        }
16136                    })
16137                    .map(|(error, code)| (commit.clone(), error, code))
16138            })
16139            .collect::<Vec<_>>();
16140        if failures.is_empty() {
16141            return Ok(false);
16142        }
16143        // §7.4.4: one observation covers the durable outcome writes, the outbox
16144        // removal, the in-memory drop, and the overlay rebuild, so a storage fault
16145        // leaves the replica and the client unchanged, a successful recovery
16146        // publishes one batch with the rejection, outcome, and table domains, and
16147        // an ordinary open with nothing to recover publishes nothing.
16148        let prior_outbox = self.outbox.clone();
16149        let prior_rejections = self.rejections.clone();
16150        // §5.11 retention and a self-published observation mutate two more
16151        // in-memory fields before this transaction ends, so the rollback below
16152        // restores them with the rest.
16153        let prior_failed_commits = self.failed_commits.clone();
16154        let prior_changes = self.change_queue.len();
16155        let prior_intents = self.sync_intent_queue.len();
16156        let prior_last_change = self.last_change.clone();
16157        let prior_overlay_dirty = self.overlay_dirty.snapshot();
16158        let owns_observation = batch.is_none();
16159        let owns_transaction = self.conn.is_autocommit();
16160        let mut domains = ChangeAccumulator::default();
16161        if owns_observation {
16162            self.begin_observation("syncular_value_recovery")?;
16163        }
16164        let dropped = (|| -> Result<(), String> {
16165            for (commit, message, code) in failures {
16166                if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
16167                    self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
16168                }
16169                let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
16170                let rejection = RejectionRecord {
16171                    client_commit_id: commit.client_commit_id.clone(),
16172                    op_index: 0,
16173                    code: code.to_owned(),
16174                    message,
16175                    retryable: false,
16176                    details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
16177                        field_paths: None,
16178                        reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
16179                        required_action: None,
16180                        references: None,
16181                    }),
16182                    operation: representable
16183                        .then(|| commit.ops.first())
16184                        .flatten()
16185                        .map(CommitOperation::from),
16186                };
16187                self.persist_commit_outcome(
16188                    &commit.client_commit_id,
16189                    CommitOutcomeStatus::Rejected,
16190                    &[CommitOperationOutcome::Error {
16191                        rejection: rejection.clone(),
16192                    }],
16193                    representable.then_some(commit.ops.as_slice()),
16194                )?;
16195                self.delete_outbox_persisted(&commit.client_commit_id)?;
16196                self.outbox
16197                    .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
16198                self.rejections.push(rejection);
16199                for operation in &commit.ops {
16200                    domains.table(operation.table.as_str());
16201                }
16202            }
16203            domains.status = true;
16204            domains.rejections = true;
16205            domains.outcomes = true;
16206            self.prune_commit_outcomes()?;
16207            // The visible projection is re-derived from the surviving base + outbox.
16208            self.overlay_dirty.set(true);
16209            self.rebuild_overlay()?;
16210            Ok(())
16211        })();
16212        let result = match batch {
16213            Some(batch) => {
16214                if dropped.is_ok() {
16215                    for table in domains.tables.keys() {
16216                        batch.table(table.as_str());
16217                    }
16218                    batch.status |= domains.status;
16219                    batch.rejections |= domains.rejections;
16220                    batch.outcomes |= domains.outcomes;
16221                }
16222                dropped
16223            }
16224            None => {
16225                dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
16226            }
16227        };
16228        if let Err(error) = result {
16229            if owns_observation {
16230                if owns_transaction {
16231                    if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
16232                        let mut failure = self.storage_failure.borrow_mut();
16233                        let failure =
16234                            failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
16235                        failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
16236                    }
16237                } else {
16238                    self.rollback_observation("syncular_value_recovery");
16239                }
16240            }
16241            self.outbox = prior_outbox;
16242            self.rejections = prior_rejections;
16243            self.failed_commits = prior_failed_commits;
16244            self.change_queue.truncate(prior_changes);
16245            self.sync_intent_queue.truncate(prior_intents);
16246            self.last_change = prior_last_change;
16247            self.overlay_dirty.restore(prior_overlay_dirty);
16248            return Err(error);
16249        }
16250        Ok(true)
16251    }
16252
16253    pub fn pending_commit_ids(&self) -> Vec<String> {
16254        self.outbox
16255            .iter()
16256            .map(|c| c.client_commit_id.clone())
16257            .collect()
16258    }
16259
16260    /// §6.1: the sparse push payload bytes of every pending upsert operation,
16261    /// FIFO — exactly what the next push round would put on the wire. Used by
16262    /// conformance B.21(h) to pin cross-core byte identity.
16263    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
16264        let mut payloads = Vec::new();
16265        for commit in &self.outbox {
16266            for op in &commit.ops {
16267                let Some(table) = self.schema.table(&op.table).filter(|_| op.upsert) else {
16268                    continue;
16269                };
16270                let Some(values) = op.presented_values(table) else {
16271                    continue;
16272                };
16273                let fallback = self.stored_key_fallback(table, &op.row_id);
16274                if let Ok(payload) = encode_sparse_row_json(
16275                    table,
16276                    &op.row_id,
16277                    &values,
16278                    &self.encryption,
16279                    fallback.as_ref(),
16280                ) {
16281                    payloads.push(payload);
16282                }
16283            }
16284        }
16285        payloads
16286    }
16287
16288    pub fn conflicts(&self) -> &[ConflictRecord] {
16289        &self.conflicts
16290    }
16291
16292    pub fn rejections(&self) -> &[RejectionRecord] {
16293        &self.rejections
16294    }
16295
16296    pub fn resolve_commit_outcome(
16297        &mut self,
16298        input: ResolveCommitOutcomeInput,
16299    ) -> Result<CommitOutcome, String> {
16300        let current = self
16301            .commit_outcome(&input.client_commit_id)?
16302            .ok_or_else(|| {
16303                format!(
16304                    "sync.outcome_not_found: no durable outcome exists for {:?}",
16305                    input.client_commit_id
16306                )
16307            })?;
16308        if current.resolution != CommitOutcomeResolution::Active {
16309            return Ok(current);
16310        }
16311        if input.resolution == CommitOutcomeResolution::Active {
16312            return Err("sync.invalid_request: resolution must leave active state".to_owned());
16313        }
16314        match input.resolution {
16315            CommitOutcomeResolution::Superseded => {
16316                let replacement = input
16317                    .replacement_client_commit_id
16318                    .as_deref()
16319                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
16320                    .ok_or_else(|| {
16321                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
16322                            .to_owned()
16323                    })?;
16324                let _ = replacement;
16325            }
16326            _ if input.replacement_client_commit_id.is_some() => {
16327                return Err(
16328                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
16329                        .to_owned(),
16330                );
16331            }
16332            _ => {}
16333        }
16334        let allowed = match current.status {
16335            CommitOutcomeStatus::Conflict => matches!(
16336                input.resolution,
16337                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
16338            ),
16339            CommitOutcomeStatus::Rejected => {
16340                matches!(
16341                    input.resolution,
16342                    CommitOutcomeResolution::Superseded
16343                        | CommitOutcomeResolution::ResolvedKeepServer
16344                )
16345            }
16346            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
16347                input.resolution == CommitOutcomeResolution::Dismissed
16348            }
16349        };
16350        if !allowed {
16351            return Err(format!(
16352                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
16353                input.resolution, current.status
16354            ));
16355        }
16356
16357        self.begin_observation("syncular_outcome_resolution")?;
16358        let previous_failed = self.failed_commits.clone();
16359        let previous_conflicts = self.conflicts.clone();
16360        let previous_rejections = self.rejections.clone();
16361        let result = (|| {
16362            self.conn
16363                .execute(
16364                    "UPDATE _syncular_commit_outcomes
16365                        SET resolution = ?1, resolved_at_ms = ?2,
16366                            replacement_client_commit_id = ?3
16367                      WHERE client_commit_id = ?4 AND resolution = 'active'",
16368                    rusqlite::params![
16369                        Self::outcome_resolution_name(input.resolution),
16370                        self.clock_now_ms(),
16371                        input.replacement_client_commit_id,
16372                        input.client_commit_id
16373                    ],
16374                )
16375                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16376            let resolved = self
16377                .commit_outcome(&current.client_commit_id)?
16378                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
16379            self.conn
16380                .execute(
16381                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
16382                    rusqlite::params![input.client_commit_id],
16383                )
16384                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16385            for (commit, _) in &self.failed_commits {
16386                if commit.client_commit_id == input.client_commit_id {
16387                    for operation in &commit.ops {
16388                        self.overlay_dirty.table(&operation.table);
16389                    }
16390                }
16391            }
16392            self.failed_commits
16393                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
16394            self.prune_commit_outcomes()?;
16395            Ok(resolved)
16396        })();
16397        let resolved = match result {
16398            Ok(outcome) => outcome,
16399            Err(error) => {
16400                self.rollback_observation("syncular_outcome_resolution");
16401                self.failed_commits = previous_failed;
16402                self.conflicts = previous_conflicts;
16403                self.rejections = previous_rejections;
16404                return Err(error);
16405            }
16406        };
16407        self.conflicts
16408            .retain(|record| record.client_commit_id != current.client_commit_id);
16409        self.rejections
16410            .retain(|record| record.client_commit_id != current.client_commit_id);
16411        let mut batch = ChangeAccumulator {
16412            conflicts: current.status == CommitOutcomeStatus::Conflict,
16413            rejections: current.status == CommitOutcomeStatus::Rejected,
16414            outcomes: true,
16415            ..ChangeAccumulator::default()
16416        };
16417        if let Some(operations) = &current.operations {
16418            for operation in operations {
16419                batch.table(&operation.table);
16420            }
16421        }
16422        if let Err(error) = self
16423            .rebuild_overlay_if_dirty()
16424            .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
16425        {
16426            self.rollback_observation("syncular_outcome_resolution");
16427            self.failed_commits = previous_failed;
16428            self.conflicts = previous_conflicts;
16429            self.rejections = previous_rejections;
16430            return Err(error);
16431        }
16432        Ok(resolved)
16433    }
16434
16435    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
16436        self.schema_floor.as_ref()
16437    }
16438
16439    /// §7.3.5: the client's opaque auth-lease state, if any.
16440    pub fn lease_state(&self) -> Option<&LeaseState> {
16441        self.lease_state.as_ref()
16442    }
16443
16444    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
16445    /// the two lease codes set it; other errors leave leaseState untouched.
16446    fn record_lease_error(&mut self, code: &str) {
16447        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
16448            return;
16449        }
16450        let mut next = self.lease_state.clone().unwrap_or_default();
16451        next.error_code = Some(code.to_owned());
16452        self.set_lease_state(Some(next));
16453    }
16454
16455    fn set_lease_state(&mut self, next: Option<LeaseState>) {
16456        if self.lease_state == next {
16457            return;
16458        }
16459        if self.begin_observation("syncular_lease").is_err() {
16460            return;
16461        }
16462        self.lease_state = next;
16463        if let Some(lease) = &self.lease_state {
16464            if let Ok(json) = serde_json::to_string(lease) {
16465                self.set_meta(LEASE_STATE_KEY, &json);
16466            }
16467        } else {
16468            self.delete_meta(LEASE_STATE_KEY);
16469        }
16470        let batch = ChangeAccumulator {
16471            status: true,
16472            ..ChangeAccumulator::default()
16473        };
16474        if self.finish_observation("syncular_lease", batch).is_err() {
16475            self.rollback_observation("syncular_lease");
16476        }
16477    }
16478
16479    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
16480        if self.schema_floor == next {
16481            return;
16482        }
16483        if self.begin_observation("syncular_schema_floor").is_err() {
16484            return;
16485        }
16486        self.schema_floor = next;
16487        self.stopped = self.schema_floor.is_some();
16488        if let Some(floor) = &self.schema_floor {
16489            if let Ok(json) = serde_json::to_string(floor) {
16490                self.set_meta(SCHEMA_FLOOR_KEY, &json);
16491            }
16492        } else {
16493            self.delete_meta(SCHEMA_FLOOR_KEY);
16494        }
16495        let batch = ChangeAccumulator {
16496            status: true,
16497            ..ChangeAccumulator::default()
16498        };
16499        if self
16500            .finish_observation("syncular_schema_floor", batch)
16501            .is_err()
16502        {
16503            self.rollback_observation("syncular_schema_floor");
16504        }
16505    }
16506
16507    fn set_upgrading(&mut self, value: bool) {
16508        if self.upgrading == value {
16509            return;
16510        }
16511        if self.begin_observation("syncular_upgrading").is_err() {
16512            return;
16513        }
16514        self.upgrading = value;
16515        let batch = ChangeAccumulator {
16516            status: true,
16517            ..ChangeAccumulator::default()
16518        };
16519        if self
16520            .finish_observation("syncular_upgrading", batch)
16521            .is_err()
16522        {
16523            self.rollback_observation("syncular_upgrading");
16524        }
16525    }
16526
16527    pub fn sync_needed(&self) -> bool {
16528        self.sync_needed
16529    }
16530
16531    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
16532        let sub = self.subs.iter().find(|s| s.id == id)?;
16533        Some(SubscriptionStateView {
16534            id: sub.id.clone(),
16535            table: sub.table.clone(),
16536            status: sub.state.name().to_owned(),
16537            cursor: sub.cursor,
16538            has_resume_token: sub.bootstrap_state.is_some(),
16539            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
16540            reason_code: sub.reason_code.clone(),
16541        })
16542    }
16543
16544    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
16545        let schema_table = self
16546            .schema
16547            .table(table)
16548            .ok_or_else(|| format!("unknown table {table:?}"))?;
16549        let sql = format!(
16550            "SELECT * FROM {} ORDER BY {} ASC",
16551            visible_table(table),
16552            quote_ident(&schema_table.primary_key)
16553        );
16554        let mut stmt = self
16555            .conn
16556            .prepare(&sql)
16557            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16558        let mut rows = stmt
16559            .query([])
16560            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16561        let mut out = Vec::new();
16562        while let Some(row) = rows
16563            .next()
16564            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16565        {
16566            let mut values = Map::new();
16567            for (i, column) in schema_table.columns.iter().enumerate() {
16568                let value = row
16569                    .get_ref(i)
16570                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16571                values.insert(column.name.clone(), sql_ref_to_json(column, value));
16572            }
16573            let version: i64 = row
16574                .get(schema_table.columns.len())
16575                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16576            let row_id = match values.get(&schema_table.primary_key) {
16577                Some(Value::String(s)) => s.clone(),
16578                Some(Value::Number(n)) => n.to_string(),
16579                Some(Value::Bool(b)) => b.to_string(),
16580                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
16581            };
16582            out.push(RowState {
16583                row_id,
16584                version,
16585                values,
16586            });
16587        }
16588        Ok(out)
16589    }
16590
16591    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
16592    //
16593    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
16594    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
16595    // the op with `yrs`, re-encodes the whole doc state, and records a
16596    // baseVersion-less sparse upsert carrying only the primary key and the
16597    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
16598    // the overlay re-materializes the edit immediately (optimistic apply,
16599    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
16600    // Byte-compatible with `@syncular/crdt-yjs`.
16601
16602    /// The current stored value of a `crdt` column for one row — the visible
16603    /// (optimistic) bytes, or `None` when the row is absent or the column is
16604    /// NULL (the empty document, §5.10.1). Errors if the column is not a
16605    /// `crdt` column (guards the app against a typo'd column name).
16606    #[cfg(feature = "crdt-yjs")]
16607    fn crdt_column_bytes(
16608        &self,
16609        table: &str,
16610        row_id: &str,
16611        column: &str,
16612    ) -> Result<Option<Vec<u8>>, String> {
16613        let schema_table = self
16614            .schema
16615            .table(table)
16616            .ok_or_else(|| format!("unknown table {table:?}"))?;
16617        let col = schema_table
16618            .columns
16619            .iter()
16620            .find(|c| c.name == column)
16621            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
16622        if col.ty != ColumnType::Crdt {
16623            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
16624        }
16625        let sql = format!(
16626            "SELECT {} FROM {} WHERE {}",
16627            quote_ident(column),
16628            visible_table(table),
16629            row_id_predicate(schema_table)
16630        );
16631        let bytes: Option<Vec<u8>> = self
16632            .conn
16633            .prepare_cached(&sql)
16634            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16635            .query_row(rusqlite::params![row_id], |row| {
16636                row.get::<_, Option<Vec<u8>>>(0)
16637            })
16638            .map_err(|e| match e {
16639                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
16640                other => Self::sqlite_failure(&self.storage_failure, other),
16641            })?;
16642        Ok(bytes)
16643    }
16644
16645    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
16646    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
16647    /// An absent row / NULL column is the empty document (empty string).
16648    #[cfg(feature = "crdt-yjs")]
16649    pub fn crdt_text(
16650        &self,
16651        table: &str,
16652        row_id: &str,
16653        column: &str,
16654        name: &str,
16655    ) -> Result<String, String> {
16656        let bytes = self
16657            .crdt_column_bytes(table, row_id, column)?
16658            .unwrap_or_default();
16659        crate::crdt::text(&bytes, name)
16660    }
16661
16662    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
16663    /// record the resulting crdt-only sparse operation. Returns the enqueued
16664    /// `clientCommitId`.
16665    #[cfg(feature = "crdt-yjs")]
16666    pub fn crdt_insert_text(
16667        &mut self,
16668        table: &str,
16669        row_id: &str,
16670        column: &str,
16671        name: &str,
16672        index: u32,
16673        value: &str,
16674    ) -> Result<String, ClientError> {
16675        let prior = self.storage_failure.borrow_mut().take();
16676        let outcome = (|| -> Result<String, String> {
16677            let current = self
16678                .crdt_column_bytes(table, row_id, column)?
16679                .unwrap_or_default();
16680            let update = crate::crdt::insert_text(&current, name, index, value)?;
16681            self.perform_crdt_push_update(table, row_id, column, &update)
16682        })();
16683        self.finish_authoring(prior, outcome)
16684    }
16685
16686    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
16687    /// record the resulting crdt-only sparse operation. Returns the enqueued
16688    /// `clientCommitId`.
16689    #[cfg(feature = "crdt-yjs")]
16690    pub fn crdt_delete_text(
16691        &mut self,
16692        table: &str,
16693        row_id: &str,
16694        column: &str,
16695        name: &str,
16696        index: u32,
16697        len: u32,
16698    ) -> Result<String, ClientError> {
16699        let prior = self.storage_failure.borrow_mut().take();
16700        let outcome = (|| -> Result<String, String> {
16701            let current = self
16702                .crdt_column_bytes(table, row_id, column)?
16703                .unwrap_or_default();
16704            let update = crate::crdt::delete_text(&current, name, index, len)?;
16705            self.perform_crdt_push_update(table, row_id, column, &update)
16706        })();
16707        self.finish_authoring(prior, outcome)
16708    }
16709
16710    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
16711    /// `crdt` column's current state and record the resulting crdt-only sparse
16712    /// operation. The app authored the update with its own `yrs` model.
16713    /// Returns the enqueued `clientCommitId`.
16714    #[cfg(feature = "crdt-yjs")]
16715    pub fn crdt_apply_update(
16716        &mut self,
16717        table: &str,
16718        row_id: &str,
16719        column: &str,
16720        update: &[u8],
16721    ) -> Result<String, ClientError> {
16722        let prior = self.storage_failure.borrow_mut().take();
16723        let outcome = (|| -> Result<String, String> {
16724            let current = self
16725                .crdt_column_bytes(table, row_id, column)?
16726                .unwrap_or_default();
16727            let next = crate::crdt::apply_update(&current, update)?;
16728            self.perform_crdt_push_update(table, row_id, column, &next)
16729        })();
16730        self.finish_authoring(prior, outcome)
16731    }
16732
16733    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
16734    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
16735    /// enqueue it. The server merges the crdt column and leaves every other
16736    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
16737    /// clobber a concurrent edit to another column. A locally absent row
16738    /// records the same partial operation; the server answers per §6.2.
16739    #[cfg(feature = "crdt-yjs")]
16740    fn perform_crdt_push_update(
16741        &mut self,
16742        table: &str,
16743        row_id: &str,
16744        column: &str,
16745        crdt_bytes: &[u8],
16746    ) -> Result<String, String> {
16747        let mut bytes_obj = Map::new();
16748        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
16749        let mut partial = Map::new();
16750        partial.insert(column.to_owned(), Value::Object(bytes_obj));
16751        self.perform_patch(table, row_id, partial, None)
16752    }
16753
16754    /// Run an arbitrary read-only SQL query against the local database and
16755    /// return each row as a `column-name → JSON value` map. This is the seam
16756    /// the React `useSyncQuery` live-query API needs (it takes app-authored
16757    /// SQL over the visible tables/views, not a fixed table read like
16758    /// [`read_rows`]).
16759    ///
16760    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
16761    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
16762    /// same envelope the command surface uses everywhere else. Output BLOB
16763    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
16764    ///
16765    /// The result column typing is dynamic (SQLite's stored affinity), because
16766    /// arbitrary SQL can alias, join, and compute — there is no schema column
16767    /// to consult per output cell, unlike [`read_rows`].
16768    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
16769        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
16770    }
16771
16772    /// Repository benchmark and conformance access, present only with `bench-internals`.
16773    #[cfg(feature = "bench-internals")]
16774    #[doc(hidden)]
16775    pub fn benchmark_connection(&mut self) -> &mut Connection {
16776        &mut self.conn
16777    }
16778
16779    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
16780    #[cfg(feature = "bench-internals")]
16781    #[doc(hidden)]
16782    pub fn benchmark_phases(
16783        &self,
16784        enabled: Option<bool>,
16785        reset: bool,
16786    ) -> Result<Option<Value>, String> {
16787        self.benchmark_phases.configure(enabled, reset)?;
16788        Ok(self.benchmark_phases.snapshot())
16789    }
16790
16791    /// One serialized, read-only authority observation, including durable coverage.
16792    pub fn authority_snapshot(&self) -> Result<Value, String> {
16793        validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
16794        if self.limits.authority_reads.is_empty() {
16795            return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
16796        }
16797        self.conn
16798            .execute_batch("SAVEPOINT authority_snapshot")
16799            .map_err(|e| e.to_string())?;
16800        let result = (|| {
16801            let revision = self
16802                .get_meta(LOCAL_REVISION_KEY)
16803                .ok_or("sync.local_corrupt: authority revision is missing")?;
16804            let parsed_revision = revision
16805                .parse::<u64>()
16806                .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
16807            if parsed_revision.to_string() != revision {
16808                return Err("sync.local_corrupt: authority revision is invalid".into());
16809            }
16810            let mut tables = Vec::new();
16811            for read in &self.limits.authority_reads {
16812                let table = self
16813                    .schema
16814                    .table(&read.table)
16815                    .ok_or("client.authority_read_forbidden: authority table is not declared")?;
16816                let scalar = |column: &str, value: &Value| -> Result<String, String> {
16817                    let ty = table
16818                        .columns
16819                        .iter()
16820                        .find(|c| c.name == column)
16821                        .expect("validated authority column")
16822                        .ty;
16823                    if ty == ColumnType::Boolean {
16824                        if let Some(value) = value.as_bool() {
16825                            return Ok(value.to_string());
16826                        }
16827                        return match value.as_i64() {
16828                            Some(0) => Ok("false".into()),
16829                            Some(1) => Ok("true".into()),
16830                            _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
16831                        };
16832                    }
16833                    match value {
16834                        Value::String(s) => Ok(s.clone()),
16835                        Value::Number(n) => Ok(n.to_string()),
16836                        Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
16837                            .get("$bigint")
16838                            .and_then(Value::as_str)
16839                            .map(str::to_owned)
16840                            .ok_or("sync.local_corrupt: authority integer is invalid".into()),
16841                        _ => {
16842                            Err("sync.local_corrupt: authority scalar is missing or invalid".into())
16843                        }
16844                    }
16845                };
16846                let matches = |row: &QueryRow| -> Result<bool, String> {
16847                    for (variable, values) in &read.scopes {
16848                        let column = table
16849                            .scope_column(variable)
16850                            .expect("validated authority scope");
16851                        let value = row
16852                            .get(column)
16853                            .ok_or("sync.local_corrupt: authority scope column is missing")?;
16854                        if value.is_null() || !values.contains(&scalar(column, value)?) {
16855                            return Ok(false);
16856                        }
16857                    }
16858                    Ok(true)
16859                };
16860                let selected = read
16861                    .columns
16862                    .iter()
16863                    .map(|c| quote_ident(c))
16864                    .collect::<Vec<_>>()
16865                    .join(",");
16866                let mut intent = BTreeSet::new();
16867                let mut relevant = BTreeSet::new();
16868                for row in query_connection(&self.conn,
16869                    "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=?",
16870                    &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
16871                    let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
16872                    intent.insert(id.clone());
16873                    if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
16874                        let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
16875                        if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
16876                    }
16877                }
16878                let mut rows = BTreeMap::new();
16879                for full in [base_table(&read.table), visible_table(&read.table)] {
16880                    for mut values in query_connection(
16881                        &self.conn,
16882                        &format!(
16883                            "SELECT {selected},_syncular_version AS authority_version FROM {full}"
16884                        ),
16885                        &[],
16886                    )
16887                    .map_err(|e| e.to_string())?
16888                    {
16889                        if !matches(&values)? {
16890                            continue;
16891                        }
16892                        let id = scalar(
16893                            &table.primary_key,
16894                            values
16895                                .get(&table.primary_key)
16896                                .expect("selected primary key"),
16897                        )?;
16898                        relevant.insert(id.clone());
16899                        if full == base_table(&read.table) {
16900                            let version = values
16901                                .remove("authority_version")
16902                                .expect("selected version");
16903                            let version = version
16904                                .as_i64()
16905                                .ok_or("sync.local_corrupt: authority server version is invalid")?;
16906                            if version >= 0 {
16907                                rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
16908                            }
16909                        }
16910                    }
16911                }
16912                intent.retain(|id| relevant.contains(id));
16913                let rows = rows.into_values().collect::<Vec<_>>();
16914                let mut persisted = Vec::new();
16915                for row in query_connection(
16916                    &self.conn,
16917                    "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
16918                    &[Value::String(read.table.clone())],
16919                )
16920                .map_err(|e| e.message)?
16921                {
16922                    let state: Value = serde_json::from_str(
16923                        row.get("state_json")
16924                            .and_then(Value::as_str)
16925                            .ok_or("sync.local_corrupt: authority subscription state is missing")?,
16926                    )
16927                    .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
16928                    let effective = state.get("effectiveScopes").and_then(Value::as_object);
16929                    let status = state
16930                        .get("status")
16931                        .and_then(Value::as_str)
16932                        .ok_or("sync.local_corrupt: authority subscription status is missing")?;
16933                    let cursor = state
16934                        .get("cursor")
16935                        .and_then(Value::as_i64)
16936                        .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
16937                    let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
16938                        && status == "active"
16939                        && cursor >= 0
16940                        && state.get("bootstrapState").is_none_or(Value::is_null)
16941                        && state.get("params").is_none_or(Value::is_null);
16942                    let exposed = effective.map(|s| {
16943                        read.scopes
16944                            .keys()
16945                            .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
16946                            .collect::<Map<_, _>>()
16947                    });
16948                    let requested = read
16949                        .scopes
16950                        .keys()
16951                        .map(|v| {
16952                            (
16953                                v.clone(),
16954                                state
16955                                    .get("requested")
16956                                    .and_then(|s| s.get(v))
16957                                    .cloned()
16958                                    .unwrap_or(json!([])),
16959                            )
16960                        })
16961                        .collect::<Map<_, _>>();
16962                    persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
16963                }
16964                fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
16965                    match selectors.split_first() {
16966                        None => !candidates.is_empty(),
16967                        Some(((variable, values), rest)) => values.iter().all(|value| {
16968                            covered(
16969                                rest,
16970                                &candidates
16971                                    .iter()
16972                                    .copied()
16973                                    .filter(|p| {
16974                                        p["effectiveScopes"][variable.as_str()]
16975                                            .as_array()
16976                                            .is_some_and(|held| {
16977                                                held.contains(&Value::String(value.clone()))
16978                                                    || held.contains(&Value::String("*".into()))
16979                                            })
16980                                    })
16981                                    .collect::<Vec<_>>(),
16982                            )
16983                        }),
16984                    }
16985                }
16986                let coverage = if covered(
16987                    &read.scopes.iter().collect::<Vec<_>>(),
16988                    &persisted
16989                        .iter()
16990                        .filter(|p| p["complete"] == true)
16991                        .collect::<Vec<_>>(),
16992                ) {
16993                    "complete"
16994                } else if persisted.iter().any(|p| {
16995                    p["status"] == "active"
16996                        && read.scopes.iter().all(|(v, values)| {
16997                            values.iter().any(|value| {
16998                                p["requestedScopes"][v].as_array().is_some_and(|held| {
16999                                    held.contains(&Value::String(value.clone()))
17000                                        || held.contains(&Value::String("*".into()))
17001                                })
17002                            })
17003                        })
17004                }) {
17005                    "pending"
17006                } else {
17007                    "missing"
17008                };
17009                tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
17010            }
17011            Ok(
17012                json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
17013            )
17014        })();
17015        match result {
17016            Ok(snapshot) => {
17017                self.conn
17018                    .execute_batch("RELEASE authority_snapshot")
17019                    .map_err(|e| e.to_string())?;
17020                Ok(snapshot)
17021            }
17022            Err(error) => {
17023                self.conn
17024                    .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
17025                    .map_err(|e| format!("{error}; rollback failed: {e}"))?;
17026                Err(error)
17027            }
17028        }
17029    }
17030
17031    /// Rows, coverage, and local revision from one SQLite read snapshot.
17032    /// §7.5 atomic snapshot read. An `owner` records a failure in
17033    /// `diagnostics_snapshot().query_failures` until its next successful read.
17034    pub fn query_snapshot(
17035        &mut self,
17036        sql: &str,
17037        params: &[QueryValue],
17038        coverage: &[WindowCoverage],
17039        owner: Option<QueryOwner<'_>>,
17040    ) -> Result<QuerySnapshot, String> {
17041        let result = snapshot_connection(&self.conn, sql, params, coverage);
17042        if let Some(owner) = owner {
17043            self.record_query_read(owner, result.as_ref().err());
17044        }
17045        result.map_err(|failure| failure.to_string())
17046    }
17047
17048    /// §7.6: record the outcome of one owned snapshot read, including reads a
17049    /// host ran on a separate connection. A success removes the owner's entry;
17050    /// an identical failure leaves it unchanged; a changed failure moves last.
17051    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
17052        let position = self
17053            .query_failures
17054            .iter()
17055            .position(|entry| entry.id == owner.id);
17056        let Some(failure) = failure else {
17057            if let Some(index) = position {
17058                self.query_failures.remove(index);
17059            }
17060            return;
17061        };
17062        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
17063            .into_iter()
17064            .map(str::to_owned)
17065            .collect();
17066        let code = failure.code.unwrap_or("client.query_failed").to_owned();
17067        if position.is_some_and(|index| {
17068            let entry = &self.query_failures[index];
17069            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
17070        }) {
17071            return;
17072        }
17073        if let Some(index) = position {
17074            self.query_failures.remove(index);
17075        }
17076        self.query_failures.push(DiagnosticQueryFailure {
17077            id: owner.id.to_owned(),
17078            tables,
17079            code,
17080            sqlite_code: failure.sqlite_code,
17081            at_ms: self.clock_now_ms(),
17082        });
17083        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
17084            self.query_failures.remove(0);
17085        }
17086    }
17087
17088    // -- request building ---------------------------------------------------------
17089
17090    fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
17091        #[cfg(feature = "bench-internals")]
17092        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
17093        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
17094        let max_commits = self.limits.max_push_commits_per_request;
17095        let max_ops = self
17096            .limits
17097            .max_push_operations_per_request
17098            .unwrap_or(PUSH_OPS_PER_REQUEST);
17099        let byte_cap = self.limits.max_push_request_bytes;
17100        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
17101        // bare URL — capability negotiation, decided per transport.
17102        let accept = self.limits.accept.unwrap_or(if url_capable {
17103            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
17104        } else {
17105            DEFAULT_ACCEPT
17106        });
17107        let header = Frame::ReqHeader {
17108            client_id: self.client_id.clone(),
17109            schema_version: self.schema.version,
17110            log_epoch: log_epoch.clone(),
17111        };
17112        // §1.5: the lead frames are REQ_HEADER plus the optional ROUND_FAILURE,
17113        // which stays ahead of every push, pull and subscription frame; the
17114        // byte-capacity probes below measure this exact sequence.
17115        let mut lead = vec![header];
17116        lead.extend(
17117            self.round_failure
17118                .iter()
17119                .map(|code| Frame::RoundFailure { code: code.clone() }),
17120        );
17121        let pull = Frame::PullHeader {
17122            limit_commits: self.limits.limit_commits.unwrap_or(0),
17123            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
17124            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
17125            accept,
17126        };
17127        let mut fresh = Vec::new();
17128        let mut sub_frames = Vec::new();
17129        for sub in &self.subs {
17130            if sub.state != SubState::Active {
17131                continue;
17132            }
17133            let mut scopes = sub.requested.clone();
17134            sort_scope_map(&mut scopes);
17135            sub_frames.push(Frame::Subscription {
17136                id: sub.id.clone(),
17137                table: sub.table.clone(),
17138                scopes,
17139                params: sub.params.clone().map(RawJson),
17140                cursor: sub.cursor,
17141                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
17142            });
17143            fresh.push((
17144                sub.id.clone(),
17145                sub.cursor < 0 && sub.bootstrap_state.is_none(),
17146            ));
17147        }
17148        // The fixed frames (order-independent size) and the header+pull probe
17149        // both encode canonically, so the byte budget is the exact wire size.
17150        let fixed_bytes = match byte_cap {
17151            Some(_) => {
17152                let mut frames = lead.clone();
17153                frames.push(pull.clone());
17154                frames.extend(sub_frames.iter().cloned());
17155                encode_message(&Message {
17156                    wire_version: WIRE_VERSION,
17157                    msg_kind: MsgKind::Request,
17158                    frames,
17159                })
17160                .len()
17161            }
17162            None => 0,
17163        };
17164        if let Some(cap) = byte_cap {
17165            if fixed_bytes > cap {
17166                return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
17167            }
17168        }
17169        let base_bytes = match byte_cap {
17170            Some(_) => {
17171                let mut frames = lead.clone();
17172                frames.push(pull.clone());
17173                encode_message(&Message {
17174                    wire_version: WIRE_VERSION,
17175                    msg_kind: MsgKind::Request,
17176                    frames,
17177                })
17178                .len()
17179            }
17180            None => 0,
17181        };
17182        let total_outbox = if log_epoch.is_some() {
17183            self.outbox.len()
17184        } else {
17185            0
17186        };
17187        let mut push_frames: Vec<Frame> = Vec::new();
17188        let mut pushed_ids = Vec::new();
17189        let mut ops_in_request = 0usize;
17190        let mut bytes_in_request = 0usize;
17191        let mut deferred_commits = 0usize;
17192        #[cfg(feature = "bench-internals")]
17193        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
17194        for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
17195            // §6.1/§7.1: the commit-count and operation caps are hard. A
17196            // first commit over the operation cap is a typed capacity error
17197            // and stays queued; a later one defers with the complete suffix.
17198            if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
17199                deferred_commits = total_outbox - index;
17200                break;
17201            }
17202            if ops_in_request + commit.ops.len() > max_ops {
17203                if push_frames.is_empty() {
17204                    return Err(Self::push_capacity_outcome(
17205                        "operations",
17206                        max_ops,
17207                        commit.ops.len(),
17208                        Some(commit.client_commit_id.as_str()),
17209                    ));
17210                }
17211                deferred_commits = total_outbox - index;
17212                break;
17213            }
17214            let operations = commit
17215                .ops
17216                .iter()
17217                .map(|op| {
17218                    let payload = self.schema.table(&op.table).and_then(|table| {
17219                        let values = op.presented_values(table)?;
17220                        // §0: outbox entries encode at send time with the
17221                        // current codec (validated at mutate()). §6.1: the
17222                        // values map's keys are the sparse presence set; an
17223                        // insert-intent operation presents every column (§7.1).
17224                        // §5.11: encrypted columns are encrypted here, with
17225                        // an absent key-id selector read from the stored row.
17226                        // `sync_inner` runs `drop_unencodable_outbox` first,
17227                        // so an encrypt failure cannot reach this encode; the
17228                        // author seam validated every other failure.
17229                        let fallback = self.stored_key_fallback(table, &op.row_id);
17230                        encode_sparse_row_json(
17231                            table,
17232                            &op.row_id,
17233                            &values,
17234                            &self.encryption,
17235                            fallback.as_ref(),
17236                        )
17237                        .ok()
17238                    });
17239                    ssp2::model::Operation {
17240                        table: op.table.clone(),
17241                        row_id: op.row_id.clone(),
17242                        op: if op.upsert { Op::Upsert } else { Op::Delete },
17243                        base_version: op.base_version,
17244                        payload,
17245                    }
17246                })
17247                .collect();
17248            let frame = Frame::PushCommit {
17249                client_commit_id: commit.client_commit_id.clone(),
17250                operations,
17251            };
17252            let frame_bytes = match byte_cap {
17253                Some(_) => {
17254                    let mut frames = lead.clone();
17255                    frames.push(frame.clone());
17256                    frames.push(pull.clone());
17257                    encode_message(&Message {
17258                        wire_version: WIRE_VERSION,
17259                        msg_kind: MsgKind::Request,
17260                        frames,
17261                    })
17262                    .len()
17263                        - base_bytes
17264                }
17265                None => 0,
17266            };
17267            // The head is always encoded so the caller can report the full
17268            // projected request size; a later commit defers once it no longer
17269            // fits.
17270            if let Some(cap) = byte_cap {
17271                if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
17272                    deferred_commits = total_outbox - index;
17273                    break;
17274                }
17275            }
17276            push_frames.push(frame);
17277            pushed_ids.push(commit.client_commit_id.clone());
17278            ops_in_request += commit.ops.len();
17279            bytes_in_request += frame_bytes;
17280        }
17281        #[cfg(feature = "bench-internals")]
17282        drop(outbox_phase);
17283        if let Some(cap) = byte_cap {
17284            if fixed_bytes + bytes_in_request > cap {
17285                // The head alone does not fit this budget; the commit stays
17286                // queued and no transport round is attempted.
17287                let blocked = if push_frames.is_empty() {
17288                    None
17289                } else {
17290                    self.outbox.first().map(|c| c.client_commit_id.as_str())
17291                };
17292                return Err(Self::push_capacity_outcome(
17293                    "bytes",
17294                    cap,
17295                    fixed_bytes + bytes_in_request,
17296                    blocked,
17297                ));
17298            }
17299        }
17300        let mut frames = lead;
17301        frames.extend(push_frames);
17302        frames.push(pull);
17303        frames.extend(sub_frames);
17304        let message = Message {
17305            wire_version: WIRE_VERSION,
17306            msg_kind: MsgKind::Request,
17307            frames,
17308        };
17309        Ok((
17310            message,
17311            RequestMeta {
17312                pushed_ids,
17313                fresh,
17314                accept,
17315                deferred_commits,
17316            },
17317        ))
17318    }
17319
17320    /// §7.1: a configured push capacity limit cannot carry this request.
17321    fn push_capacity_outcome(
17322        kind: &str,
17323        limit: usize,
17324        size: usize,
17325        client_commit_id: Option<&str>,
17326    ) -> Box<SyncOutcome> {
17327        let mut details = Map::new();
17328        details.insert("kind".to_owned(), Value::from(kind));
17329        details.insert("limit".to_owned(), Value::from(limit as u64));
17330        details.insert("size".to_owned(), Value::from(size as u64));
17331        if let Some(id) = client_commit_id {
17332            details.insert("clientCommitId".to_owned(), Value::from(id));
17333        }
17334        Box::new(SyncOutcome::Failed {
17335            error_code: "client.push_request_too_large".into(),
17336            message: "the push request exceeds a configured capacity limit".into(),
17337            details: Some(Value::Object(details)),
17338            recommended_action: None,
17339        })
17340    }
17341
17342    // -- sync -------------------------------------------------------------------
17343
17344    /// Cloneable observation handle; no client lock is needed to read it.
17345    pub fn progress(&self) -> ProgressObserver {
17346        self.progress.clone()
17347    }
17348
17349    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
17350        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
17351            Ok(prepared) => prepared,
17352            Err(outcome) => return *outcome,
17353        };
17354        // The round's network deadline rides `PreparedSyncRound` and is scoped
17355        // to each exchange, so uploads, continuations, the main request, and
17356        // every segment fetch share one monotonic anchor without leaving
17357        // deadline state on a reused transport.
17358        let mut next = prepared;
17359        loop {
17360            match self.apply_sync_round(next.exchange(transport)) {
17361                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
17362                crate::AppliedSyncRound::Complete {
17363                    outcome, controls, ..
17364                } => {
17365                    if self.transport_enabled {
17366                        for text in controls {
17367                            let _ = transport.realtime_send(&text);
17368                        }
17369                    } else {
17370                        self.disconnect_realtime(transport);
17371                    }
17372                    return outcome;
17373                }
17374            }
17375        }
17376    }
17377
17378    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
17379        if let SyncOutcome::Failed {
17380            error_code,
17381            message,
17382            details,
17383            ..
17384        } = &mut outcome
17385        {
17386            if let Some(failure) = self.storage_failure.borrow().as_ref() {
17387                let code = failure
17388                    .code
17389                    .expect("retained storage failures are classified");
17390                *error_code = code.to_owned();
17391                *message = match code {
17392                    "client.storage_busy" => "local SQLite storage is busy",
17393                    "client.storage_full" => "local SQLite storage is full",
17394                    "client.storage_io" => "local SQLite storage I/O failed",
17395                    "client.storage_corrupt" => "local SQLite storage is corrupt",
17396                    _ => unreachable!("unrecognized classified storage failure"),
17397                }
17398                .to_owned();
17399                *details = failure.details();
17400            }
17401        }
17402        let retry_delay_ms = self.round_retry_delay_ms;
17403        self.progress.update(|p| match &outcome {
17404            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
17405                if report.failed.is_empty() =>
17406            {
17407                p.state = ProgressState::Complete
17408            }
17409            SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
17410                p.state = ProgressState::Failed;
17411                p.error_code = Some("sync.scope_revoked".into());
17412            }
17413            SyncOutcome::Failed { error_code, .. } => {
17414                p.state = ProgressState::Failed;
17415                p.error_code = Some(Self::diagnostic_code(error_code));
17416                p.retry_delay_ms = retry_delay_ms;
17417            }
17418            SyncOutcome::RealtimeUnavailable { .. } => {
17419                p.state = ProgressState::Failed;
17420                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
17421                p.retry_delay_ms = retry_delay_ms;
17422            }
17423        });
17424        let completed_at_ms = self.clock_now_ms();
17425        self.last_round = Some(match &outcome {
17426            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
17427                status: "succeeded".to_owned(),
17428                started_at_ms,
17429                completed_at_ms,
17430                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17431                counters: Some(DiagnosticRoundCounters {
17432                    pushed: report.pushed,
17433                    applied: report.applied.len(),
17434                    rejected: report.rejected.len(),
17435                    retryable: report.retryable.len(),
17436                    conflicts: report.conflicts,
17437                    commits_applied: report.commits_applied,
17438                    segment_rows_applied: report.segment_rows_applied,
17439                    bootstrapping: report.bootstrapping.len(),
17440                    resets: report.resets.len(),
17441                    revoked: report.revoked.len(),
17442                    failed: report.failed.len(),
17443                    deferred_commits: report.deferred_commits,
17444                }),
17445                error_code: None,
17446            },
17447            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
17448                status: "failed".to_owned(),
17449                started_at_ms,
17450                completed_at_ms,
17451                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17452                counters: None,
17453                error_code: Some(Self::diagnostic_code(error_code)),
17454            },
17455            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
17456                status: "failed".to_owned(),
17457                started_at_ms,
17458                completed_at_ms,
17459                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17460                counters: None,
17461                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
17462            },
17463        });
17464        outcome
17465    }
17466
17467    /// Capture one request on the client owner. Mutations made after this
17468    /// call remain in the outbox for a later round; no network I/O occurs here.
17469    pub fn prepare_sync_round(
17470        &mut self,
17471        url_capable: bool,
17472    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
17473        if !self.transport_enabled {
17474            return Err(Box::new(SyncOutcome::Failed {
17475                error_code: "sync.offline".into(),
17476                message: "transport gate is closed".into(),
17477                details: None,
17478                recommended_action: None,
17479            }));
17480        }
17481        if self.active_round.is_some() {
17482            return Err(Box::new(SyncOutcome::Failed {
17483                error_code: "client.round_in_progress".into(),
17484                message: "a sync round is already in flight".into(),
17485                details: None,
17486                recommended_action: None,
17487            }));
17488        }
17489        self.storage_failure.borrow_mut().take();
17490        self.progress.start();
17491        self.round_retry_delay_ms = None;
17492        let started_at_ms = self.clock_now_ms();
17493        let prepared = (|| {
17494            if self.stopped {
17495                return Err(Box::new(SyncOutcome::Ok(SyncReport {
17496                    schema_floor: self.schema_floor.clone(),
17497                    ..SyncReport::default()
17498                })));
17499            }
17500            if self.security_preflight {
17501                return Err(Box::new(SyncOutcome::Failed {
17502                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
17503                    message: "security preflight must complete before syncing".into(),
17504                    details: None,
17505                    recommended_action: None,
17506                }));
17507            }
17508            self.set_sync_needed(false, false);
17509            self.drain_pending_evictions()
17510                .and_then(|_| self.drop_incompatible_outbox())
17511                .and_then(|_| self.drop_unencodable_outbox(true, None))
17512                .map_err(|message| {
17513                    Box::new(SyncOutcome::Failed {
17514                        error_code: "storage.failed".into(),
17515                        message,
17516                        details: None,
17517                        recommended_action: None,
17518                    })
17519                })?;
17520            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
17521                self.pending_blob_uploads().map_err(|e| {
17522                    Box::new(SyncOutcome::Failed {
17523                        error_code: e.code,
17524                        message: e.message,
17525                        details: e.details,
17526                        recommended_action: None,
17527                    })
17528                })?
17529            } else {
17530                Vec::new()
17531            };
17532            if self.realtime_state != RealtimeState::Connected
17533                && self.realtime_policy == RealtimePolicy::Required
17534            {
17535                let retry_delay_ms = self.schedule_background_retry();
17536                self.realtime_retry_delay_ms = Some(retry_delay_ms);
17537                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
17538                    state: self.realtime_state,
17539                    reason_code: self.realtime_reason_code.clone(),
17540                    retry_delay_ms,
17541                }));
17542            }
17543            let (message, meta) = self.build_request(url_capable)?;
17544            let id = uuid::Uuid::new_v4();
17545            self.active_round = Some(id);
17546            let phase = if uploads.is_empty() {
17547                crate::round::RoundPhase::Sync
17548            } else {
17549                crate::round::RoundPhase::UploadGrant
17550            };
17551            Ok(crate::PreparedSyncRound {
17552                id,
17553                started_at_ms,
17554                message,
17555                meta,
17556                uploads: uploads.into(),
17557                phase,
17558                encoded: None,
17559                continuation: None,
17560                completed: None,
17561                realtime: self.realtime_state == RealtimeState::Connected,
17562                scopes: self
17563                    .subs
17564                    .iter()
17565                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
17566                    .collect(),
17567                #[cfg(feature = "bench-internals")]
17568                benchmark_phases: self.benchmark_phases.clone(),
17569                progress: self.progress.clone(),
17570                fixed_now: self.now_ms,
17571                round_deadline_at: None,
17572            })
17573        })();
17574        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
17575    }
17576
17577    /// Discard the reply of an old authorization/subscription context. Its
17578    /// submitted commits stay durable and can be retried with the same ids.
17579    pub fn cancel_sync_round(&mut self) {
17580        self.active_round = None;
17581    }
17582
17583    /// Apply captured network results on the owner, preserving all normal
17584    /// version, schema, content-address and cursor checks. No network I/O.
17585    pub fn apply_sync_round(
17586        &mut self,
17587        completed: crate::CompletedSyncRound,
17588    ) -> crate::AppliedSyncRound {
17589        let prepared = completed.prepared;
17590        if self.active_round != Some(prepared.id) || self.security_preflight {
17591            return crate::AppliedSyncRound::Complete {
17592                outcome: SyncOutcome::Failed {
17593                    error_code: "client.round_cancelled".into(),
17594                    message: "sync round belongs to a released client context".into(),
17595                    details: None,
17596                    recommended_action: None,
17597                },
17598                controls: Vec::new(),
17599                more: false,
17600                bootstrap_advanced: false,
17601            };
17602        }
17603        self.storage_failure.borrow_mut().take();
17604        let (response, mut downloads, transport_failed) = match completed.exchange {
17605            crate::round::ExchangeResult::Upload { id, result } => {
17606                let result = result.and_then(|()| {
17607                    self.conn
17608                        .execute(
17609                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
17610                            rusqlite::params![id],
17611                        )
17612                        .map(|_| ())
17613                        .map_err(|error| {
17614                            TransportError::new(
17615                                "client.failed",
17616                                Self::sqlite_failure(&self.storage_failure, error),
17617                            )
17618                        })
17619                });
17620                match result {
17621                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
17622                    Err(TransportError {
17623                        code,
17624                        message,
17625                        details,
17626                    }) => {
17627                        self.active_round = None;
17628                        if Self::retryable_failure_code(&code)
17629                            && !Self::unreachable_transport(&details)
17630                        {
17631                            self.schedule_background_retry();
17632                        }
17633                        let recommended_action = recommended_action_of(&details);
17634                        let outcome = self.finish_sync_round(
17635                            prepared.started_at_ms,
17636                            SyncOutcome::Failed {
17637                                error_code: code,
17638                                message,
17639                                details,
17640                                recommended_action,
17641                            },
17642                        );
17643                        return crate::AppliedSyncRound::Complete {
17644                            outcome,
17645                            controls: Vec::new(),
17646                            more: false,
17647                            bootstrap_advanced: false,
17648                        };
17649                    }
17650                }
17651            }
17652            crate::round::ExchangeResult::Reply {
17653                response,
17654                downloads,
17655                transport_failed,
17656            } => (response, downloads, transport_failed),
17657        };
17658        self.active_round = None;
17659        let before = self
17660            .subs
17661            .iter()
17662            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17663            .collect::<Vec<_>>();
17664        let outcome = (|| {
17665            let response = match response {
17666                Ok(response) => {
17667                    // §1.5: a complete, decodable response ends the report, an
17668                    // in-band ERROR included (the server already recorded it).
17669                    self.round_failure = None;
17670                    response
17671                }
17672                Err(TransportError {
17673                    code,
17674                    message,
17675                    details,
17676                }) => {
17677                    // §1.5: the server cannot see a round whose transport
17678                    // failed or whose response never decoded, so its code rides
17679                    // the following requests. A code the frame grammar rejects
17680                    // is not reportable, and the previous code stays.
17681                    if ssp2::model::valid_round_failure_code(&code) {
17682                        self.round_failure = Some(code.clone());
17683                    }
17684                    if transport_failed {
17685                        if prepared.realtime {
17686                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
17687                        }
17688                        self.record_lease_error(&code);
17689                    }
17690                    if Self::retryable_failure_code(&code) && !Self::unreachable_transport(&details)
17691                    {
17692                        let delay = self.schedule_background_retry();
17693                        if prepared.realtime && transport_failed {
17694                            self.realtime_retry_delay_ms = Some(delay);
17695                        }
17696                    }
17697                    let recommended_action = recommended_action_of(&details);
17698                    return SyncOutcome::Failed {
17699                        error_code: code,
17700                        message,
17701                        details,
17702                        recommended_action,
17703                    };
17704                }
17705            };
17706            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
17707            if let SyncOutcome::Ok(report) = &mut outcome {
17708                report.deferred_commits = prepared.meta.deferred_commits;
17709            }
17710            match &outcome {
17711                SyncOutcome::Ok(_) => self.reset_background_retry(),
17712                SyncOutcome::Failed {
17713                    error_code,
17714                    details,
17715                    ..
17716                } if Self::retryable_failure_code(error_code)
17717                    && !Self::unreachable_transport(details) =>
17718                {
17719                    self.schedule_background_retry();
17720                }
17721                _ => {}
17722            }
17723            if prepared.meta.deferred_commits > 0 {
17724                self.set_sync_needed(true, true);
17725            }
17726            self.previous_version_lifetime_check();
17727            outcome
17728        })();
17729        let more = self.transport_enabled
17730            && matches!(&outcome, SyncOutcome::Ok(report)
17731                if report.schema_floor.is_none()
17732                    && (!report.bootstrapping.is_empty()
17733                        || report.commits_applied > 0
17734                        || report.segment_rows_applied > 0
17735                        || !report.resets.is_empty()
17736                        || report.deferred_commits > 0
17737                        || self.sync_needed));
17738        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
17739            && before
17740                != self
17741                    .subs
17742                    .iter()
17743                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17744                    .collect::<Vec<_>>();
17745        crate::AppliedSyncRound::Complete {
17746            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
17747            controls: if self.transport_enabled {
17748                downloads.controls
17749            } else {
17750                Vec::new()
17751            },
17752            more,
17753            bootstrap_advanced,
17754        }
17755    }
17756
17757    /// §7.7: one idle drive's default total deadline, the TS client's value.
17758    pub const SYNC_IDLE_TIMEOUT_MS: u64 = 60_000;
17759
17760    pub fn sync_until_idle(
17761        &mut self,
17762        transport: &mut dyn Transport,
17763        max_rounds: Option<u32>,
17764    ) -> SyncOutcome {
17765        self.sync_until_idle_with_deadline(transport, max_rounds, Some(Self::SYNC_IDLE_TIMEOUT_MS))
17766    }
17767
17768    /// As [`SyncClient::sync_until_idle`], with an explicit total deadline in
17769    /// milliseconds (`None` disables it). The deadline is checked between
17770    /// rounds; one blocking transport call is bounded by the transport's own
17771    /// whole-round deadline (§1.8).
17772    pub fn sync_until_idle_with_deadline(
17773        &mut self,
17774        transport: &mut dyn Transport,
17775        max_rounds: Option<u32>,
17776        timeout_ms: Option<u64>,
17777    ) -> SyncOutcome {
17778        // §7.7: an explicit budget is a strict positive round count. Zero is a
17779        // caller error, not an exhaustion.
17780        if max_rounds == Some(0) {
17781            return SyncOutcome::Failed {
17782                details: None,
17783                error_code: "sync.invalid_request".into(),
17784                message: "maxRounds must be a positive integer".into(),
17785                recommended_action: None,
17786            };
17787        }
17788        if timeout_ms.is_some_and(|ms| ms == 0 || i64::try_from(ms).is_err()) {
17789            return SyncOutcome::Failed {
17790                details: None,
17791                error_code: "sync.invalid_request".into(),
17792                message: "timeoutMs must be a positive integer".into(),
17793                recommended_action: None,
17794            };
17795        }
17796        let started_at_ms = self.clock_now_ms();
17797        let deadline_ms = timeout_ms.map(|ms| started_at_ms.saturating_add(ms as i64));
17798        self.sync_until_idle_bounded(transport, max_rounds, started_at_ms, deadline_ms)
17799    }
17800
17801    fn sync_until_idle_bounded(
17802        &mut self,
17803        transport: &mut dyn Transport,
17804        max_rounds: Option<u32>,
17805        started_at_ms: i64,
17806        deadline_ms: Option<i64>,
17807    ) -> SyncOutcome {
17808        let rounds = max_rounds.unwrap_or(20);
17809        let mut aggregate = SyncReport::default();
17810        let mut spent = 0;
17811        while spent < rounds {
17812            if let Some(deadline_ms) = deadline_ms {
17813                if self.clock_now_ms() >= deadline_ms {
17814                    return SyncOutcome::Failed {
17815                        error_code: "sync.idle_timeout".into(),
17816                        message: "sync_until_idle exceeded its deadline".into(),
17817                        details: Some(serde_json::json!({
17818                            "timeoutMs": deadline_ms
17819                                .saturating_sub(started_at_ms) as u64,
17820                            "rounds": spent,
17821                        })),
17822                        recommended_action: Some("retryLater".into()),
17823                    };
17824                }
17825            }
17826            spent += 1;
17827            let before = self
17828                .subs
17829                .iter()
17830                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17831                .collect::<Vec<_>>();
17832            match self.sync(transport) {
17833                SyncOutcome::Failed {
17834                    error_code,
17835                    message,
17836                    details,
17837                    recommended_action,
17838                } => {
17839                    return SyncOutcome::Failed {
17840                        details,
17841                        error_code,
17842                        message,
17843                        recommended_action,
17844                    };
17845                }
17846                outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
17847                SyncOutcome::BudgetExhausted(_) => {
17848                    unreachable!("sync() never exhausts a round budget")
17849                }
17850                SyncOutcome::Ok(report) => {
17851                    if max_rounds.is_none()
17852                        && report.segment_rows_applied > 0
17853                        && !report.bootstrapping.is_empty()
17854                        && before
17855                            != self
17856                                .subs
17857                                .iter()
17858                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17859                                .collect::<Vec<_>>()
17860                    {
17861                        spent = 0;
17862                    }
17863                    // TS parity: a disabled transport or a schema floor ends
17864                    // the loop. The latest round still merges first.
17865                    let stop = !self.transport_enabled() || report.schema_floor.is_some();
17866                    aggregate.merge(&report);
17867                    if stop {
17868                        return SyncOutcome::Ok(aggregate);
17869                    }
17870                    // §4.5: pull again whenever the response contained
17871                    // commits or segments; resets re-bootstrap; a pending
17872                    // resume token continues paging (§4.7); deferred outbox
17873                    // commits and a raised sync-needed signal push on the next
17874                    // round.
17875                    let more = !report.bootstrapping.is_empty()
17876                        || report.commits_applied > 0
17877                        || report.segment_rows_applied > 0
17878                        || !report.resets.is_empty()
17879                        || report.deferred_commits > 0
17880                        || self.sync_needed;
17881                    if !more {
17882                        return SyncOutcome::Ok(aggregate);
17883                    }
17884                }
17885            }
17886        }
17887        // §7.7: partial success. The aggregate report is retained and the run
17888        // is explicitly not idle.
17889        SyncOutcome::BudgetExhausted(aggregate)
17890    }
17891
17892    fn process_response(
17893        &mut self,
17894        transport: &mut dyn Transport,
17895        response: Message,
17896        meta: &RequestMeta,
17897    ) -> SyncOutcome {
17898        #[cfg(feature = "bench-internals")]
17899        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
17900        let mut report = SyncReport {
17901            pushed: meta.pushed_ids.len() as u32,
17902            ..SyncReport::default()
17903        };
17904        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
17905            HashMap::new();
17906        let pushed_ids = meta
17907            .pushed_ids
17908            .iter()
17909            .map(String::as_str)
17910            .collect::<HashSet<_>>();
17911        let mut last_final_push_result_id: Option<String> = None;
17912        for frame in &response.frames {
17913            match frame {
17914                Frame::PushResultDetails {
17915                    client_commit_id,
17916                    entries,
17917                } => {
17918                    let details = rejection_details_by_commit
17919                        .entry(client_commit_id.clone())
17920                        .or_default();
17921                    for entry in entries {
17922                        let parsed = match RejectionDetails::parse(&entry.details.0) {
17923                            Ok(value) => value,
17924                            Err(message) => {
17925                                return SyncOutcome::Failed {
17926                                    details: None,
17927                                    error_code: "sync.invalid_request".to_owned(),
17928                                    message,
17929                                    recommended_action: None,
17930                                };
17931                            }
17932                        };
17933                        details.insert(entry.op_index, parsed);
17934                    }
17935                }
17936                Frame::PushResult {
17937                    client_commit_id,
17938                    status,
17939                    results,
17940                    ..
17941                } if pushed_ids.contains(client_commit_id.as_str())
17942                    && Self::push_result_is_final(*status, results) =>
17943                {
17944                    last_final_push_result_id = Some(client_commit_id.clone());
17945                }
17946                _ => {}
17947            }
17948        }
17949        if response.wire_version < 2 {
17950            return SyncOutcome::Failed {
17951                details: None,
17952                error_code: "client.invalid_host_response".to_owned(),
17953                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
17954                recommended_action: None,
17955            };
17956        }
17957        let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
17958            status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
17959        });
17960        let mut frames = response.frames.into_iter().peekable();
17961        match frames.next() {
17962            Some(Frame::RespHeader {
17963                required_schema_version,
17964                latest_schema_version,
17965                log_epoch,
17966                reset_required,
17967            }) => {
17968                let Some(log_epoch) = log_epoch else {
17969                    return SyncOutcome::Failed {
17970                        details: None,
17971                        error_code: "client.invalid_host_response".to_owned(),
17972                        message: "response header omits logEpoch".to_owned(),
17973                        recommended_action: None,
17974                    };
17975                };
17976                let Some(reset_required) = reset_required else {
17977                    return SyncOutcome::Failed {
17978                        details: None,
17979                        error_code: "client.invalid_host_response".to_owned(),
17980                        message: "response header omits resetRequired".to_owned(),
17981                        recommended_action: None,
17982                    };
17983                };
17984                if let Some(required) = required_schema_version {
17985                    // §1.6 schema-floor response: nothing else is processed;
17986                    // stop syncing and surface the upgrade requirement.
17987                    let floor = SchemaFloor {
17988                        required_schema_version: Some(required),
17989                        latest_schema_version,
17990                    };
17991                    self.set_schema_floor(Some(floor.clone()));
17992                    report.schema_floor = Some(floor);
17993                    return SyncOutcome::Ok(report);
17994                }
17995                if reset_required {
17996                    if frames.next().is_some() {
17997                        return SyncOutcome::Failed {
17998                            details: None,
17999                            error_code: "client.invalid_host_response".to_owned(),
18000                            message: "log-epoch reset response contains body frames".to_owned(),
18001                            recommended_action: None,
18002                        };
18003                    }
18004                    match self.run_log_epoch_reset(&log_epoch) {
18005                        Ok(resets) => {
18006                            report.resets = resets;
18007                            return SyncOutcome::Ok(report);
18008                        }
18009                        Err(message) => {
18010                            return SyncOutcome::Failed {
18011                                details: None,
18012                                error_code: "sync.local_corrupt".to_owned(),
18013                                message,
18014                                recommended_action: None,
18015                            };
18016                        }
18017                    }
18018                }
18019                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
18020                    return SyncOutcome::Failed {
18021                        details: None,
18022                        error_code: "client.invalid_host_response".to_owned(),
18023                        message: "server changed logEpoch without requiring a reset".to_owned(),
18024                        recommended_action: None,
18025                    };
18026                }
18027            }
18028            _ => {
18029                return SyncOutcome::Failed {
18030                    details: None,
18031                    error_code: "sync.invalid_request".to_owned(),
18032                    message: "response does not start with RESP_HEADER".to_owned(),
18033                    recommended_action: None,
18034                };
18035            }
18036        }
18037
18038        if let Err(message) = self.begin_coalesced_apply() {
18039            return SyncOutcome::Failed {
18040                details: None,
18041                error_code: "storage.failed".into(),
18042                message,
18043                recommended_action: None,
18044            };
18045        }
18046        let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
18047        while let Some(frame) = frames.next() {
18048            match frame {
18049                frame @ Frame::PushResult { .. } => {
18050                    let successful = matches!(
18051                        &frame,
18052                        Frame::PushResult {
18053                            status: PushStatus::Applied | PushStatus::Cached,
18054                            ..
18055                        }
18056                    );
18057                    let mut results = vec![frame];
18058                    if successful {
18059                        while matches!(
18060                            frames.peek(),
18061                            Some(Frame::PushResult {
18062                                status: PushStatus::Applied | PushStatus::Cached,
18063                                ..
18064                            })
18065                        ) {
18066                            if let Some(next) = frames.next() {
18067                                results.push(next);
18068                            }
18069                        }
18070                    }
18071                    if self
18072                        .handle_push_results(
18073                            &results,
18074                            &pushed_ids,
18075                            &rejection_details_by_commit,
18076                            &mut report,
18077                            last_final_push_result_id.as_deref(),
18078                        )
18079                        .is_err()
18080                    {
18081                        failure = Some((
18082                            "client.outcome_persistence_failed".to_owned(),
18083                            "local commit outcome could not be persisted".to_owned(),
18084                            None,
18085                            None,
18086                        ));
18087                        break;
18088                    }
18089                }
18090                Frame::PushResultDetails { .. } => {}
18091                Frame::SubStart {
18092                    id,
18093                    status,
18094                    reason_code,
18095                    effective_scopes,
18096                    bootstrap: _,
18097                } => {
18098                    let mut body = Vec::new();
18099                    let mut sub_end: Option<(i64, Option<String>)> = None;
18100                    for inner in frames.by_ref() {
18101                        match inner {
18102                            Frame::SubEnd {
18103                                next_cursor,
18104                                bootstrap_state,
18105                            } => {
18106                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
18107                                break;
18108                            }
18109                            Frame::Unknown { .. } => {}
18110                            other => body.push(other),
18111                        }
18112                    }
18113                    if let Err(SectionError::Abort(code, message)) = self.process_section(
18114                        transport,
18115                        &id,
18116                        status,
18117                        &reason_code,
18118                        effective_scopes,
18119                        body,
18120                        sub_end,
18121                        meta,
18122                        &mut report,
18123                        &coalesced_deletes,
18124                    ) {
18125                        failure = Some((code, message, None, None));
18126                        break;
18127                    }
18128                }
18129                Frame::Lease {
18130                    lease_id,
18131                    expires_at_ms,
18132                } => {
18133                    // §7.3.5: persist the opaque lease; a fresh lease clears
18134                    // any prior lease error (the outage/revocation is over).
18135                    self.set_lease_state(Some(LeaseState {
18136                        lease_id: Some(lease_id),
18137                        expires_at_ms: Some(expires_at_ms),
18138                        error_code: None,
18139                    }));
18140                }
18141                Frame::Error {
18142                    code,
18143                    message,
18144                    recommended_action,
18145                    details,
18146                    ..
18147                } => {
18148                    // §1.6: the whole request failed; already-completed
18149                    // subscriptions keep their applied data and cursors.
18150                    let recommended_action =
18151                        (!recommended_action.is_empty()).then_some(recommended_action);
18152                    let details = details
18153                        .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
18154                        .filter(Value::is_object);
18155                    failure = Some((code, message, recommended_action, details));
18156                    break;
18157                }
18158                Frame::Unknown { .. } => {}
18159                _ => {
18160                    failure = Some((
18161                        "sync.invalid_request".to_owned(),
18162                        "unexpected frame in response".to_owned(),
18163                        None,
18164                        None,
18165                    ));
18166                    break;
18167                }
18168            }
18169        }
18170
18171        let applied = match failure {
18172            Some(failure) => Err(failure),
18173            // Local apply boundaries already reconciled their affected overlay.
18174            // Refuse round success if any remaining dirty work fails to
18175            // reconcile. §4.8 E1: the push half may have drained commits that
18176            // pinned rows of a shrunk window unit, so retry deferred evictions.
18177            None => self
18178                .rebuild_overlay_if_dirty()
18179                .and_then(|()| self.drain_pending_evictions())
18180                .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
18181        };
18182        // §1.4 rule 4: frames completed before a failure stay committed; a
18183        // failed COMMIT of the coalesced transaction fails the round.
18184        if let Err(message) = self.finish_coalesced_apply() {
18185            return SyncOutcome::Failed {
18186                details: None,
18187                error_code: "storage.failed".into(),
18188                message,
18189                recommended_action: None,
18190            };
18191        }
18192        if let Err((error_code, message, recommended_action, details)) = applied {
18193            return SyncOutcome::Failed {
18194                details,
18195                error_code,
18196                message,
18197                recommended_action,
18198            };
18199        }
18200        self.ack_after_pull(transport);
18201        // §7.4.5: the reset is over once the first post-reset pull round
18202        // leaves no subscription mid-bootstrap — the tables are rebuilt.
18203        if self.upgrading && report.bootstrapping.is_empty() {
18204            self.set_upgrading(false);
18205        }
18206        SyncOutcome::Ok(report)
18207    }
18208
18209    // -- push results (§6.3, §7.2) ------------------------------------------------
18210
18211    fn handle_push_results(
18212        &mut self,
18213        frames: &[Frame],
18214        pushed_ids: &HashSet<&str>,
18215        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
18216        report: &mut SyncReport,
18217        last_final_push_result_id: Option<&str>,
18218    ) -> Result<(), String> {
18219        self.begin_observation("syncular_push_results")?;
18220        let previous_failed = self.failed_commits.clone();
18221        let previous_sync_needed = self.sync_needed;
18222        let conflict_count = self.conflicts.len();
18223        let rejection_count = self.rejections.len();
18224        let applied_count = report.applied.len();
18225        let rejected_count = report.rejected.len();
18226        let retryable_count = report.retryable.len();
18227        let was_dirty = self.overlay_dirty.snapshot();
18228        let mut removed = Vec::new();
18229        let mut batch = ChangeAccumulator::default();
18230        let persisted = (|| {
18231            for frame in frames {
18232                let Frame::PushResult {
18233                    client_commit_id,
18234                    status,
18235                    results,
18236                    commit_seq,
18237                } = frame
18238                else {
18239                    return Err("unexpected acknowledgement frame".to_owned());
18240                };
18241                if !pushed_ids.contains(client_commit_id.as_str()) {
18242                    continue;
18243                }
18244                let Some(index) = self
18245                    .outbox
18246                    .iter()
18247                    .position(|commit| commit.client_commit_id == *client_commit_id)
18248                else {
18249                    continue;
18250                };
18251                let status = *status;
18252                let rejection_details = rejection_details_by_commit.get(client_commit_id);
18253                let operations = self.outbox[index].ops.clone();
18254                match status {
18255                    PushStatus::Applied | PushStatus::Cached => {
18256                        // §7.2: a lost ack replays as `cached` — proceed as if the
18257                        // ack had arrived.
18258                        let sequence =
18259                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
18260                        let affected: HashSet<_> = operations
18261                            .iter()
18262                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
18263                            .collect();
18264                        let prior = self.acknowledged_operations(Some(&affected), None)?;
18265                        for (op_index, operation) in operations.iter().enumerate() {
18266                            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))
18267                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18268                            if delivered {
18269                                continue;
18270                            }
18271                            let table = self
18272                                .schema
18273                                .table(&operation.table)
18274                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
18275                            let mut intended: Map<String, Value> = self
18276                                .stored_row(table, &operation.row_id, true)?
18277                                .map(|(row, _)| {
18278                                    table
18279                                        .columns
18280                                        .iter()
18281                                        .zip(&row)
18282                                        .map(|(column, value)| {
18283                                            (column.name.clone(), column_value_to_json(value))
18284                                        })
18285                                        .collect()
18286                                })
18287                                .unwrap_or_default();
18288                            let mut scope_image = intended.clone();
18289                            for op in prior
18290                                .iter()
18291                                .chain(
18292                                    self.failed_commits
18293                                        .iter()
18294                                        .flat_map(|(commit, _)| &commit.ops),
18295                                )
18296                                .chain(
18297                                    self.outbox
18298                                        .iter()
18299                                        .take(index)
18300                                        .flat_map(|commit| &commit.ops),
18301                                )
18302                                .chain(operations.iter().take(op_index + 1))
18303                            {
18304                                if op.table != operation.table || op.row_id != operation.row_id {
18305                                    continue;
18306                                }
18307                                if let Some(values) = &op.values {
18308                                    intended.extend(values.clone());
18309                                } else {
18310                                    scope_image = intended.clone();
18311                                    intended.clear();
18312                                }
18313                            }
18314                            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)",
18315                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
18316                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
18317                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
18318                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18319                        }
18320                        let journal_results = results
18321                            .iter()
18322                            .map(|result| {
18323                                let op_index = match result {
18324                                    OpResult::Applied { op_index }
18325                                    | OpResult::Conflict { op_index, .. }
18326                                    | OpResult::Error { op_index, .. } => *op_index,
18327                                };
18328                                CommitOperationOutcome::Applied { op_index }
18329                            })
18330                            .collect::<Vec<_>>();
18331                        let outcome_status = if status == PushStatus::Applied {
18332                            CommitOutcomeStatus::Applied
18333                        } else {
18334                            CommitOutcomeStatus::Cached
18335                        };
18336                        let persisted = self
18337                            .persist_commit_outcome(
18338                                client_commit_id,
18339                                outcome_status,
18340                                &journal_results,
18341                                None,
18342                            )
18343                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18344                        persisted?;
18345                        for operation in &operations {
18346                            self.overlay_dirty.table(&operation.table);
18347                        }
18348                        self.sync_needed = true;
18349                        batch.status = true;
18350                        batch.outcomes = true;
18351                    }
18352                    PushStatus::Rejected => {
18353                        if results.iter().any(|result| {
18354                            matches!(
18355                                result,
18356                                OpResult::Error {
18357                                    code,
18358                                    retryable: true,
18359                                    ..
18360                                } if code == "sync.idempotency_cache_miss"
18361                            )
18362                        }) {
18363                            // §6.3/§7.2: a serving failure, not an outcome — keep the
18364                            // exact commit queued for an identical retry.
18365                            report.retryable.push(client_commit_id.to_owned());
18366                            continue;
18367                        }
18368
18369                        let mut journal_results = Vec::with_capacity(results.len());
18370                        let mut conflicts = Vec::new();
18371                        let mut rejections = Vec::new();
18372                        for result in results {
18373                            match result {
18374                                OpResult::Applied { op_index } => {
18375                                    journal_results.push(CommitOperationOutcome::Applied {
18376                                        op_index: *op_index,
18377                                    });
18378                                }
18379                                OpResult::Conflict {
18380                                    op_index,
18381                                    code,
18382                                    message,
18383                                    server_version,
18384                                    server_row,
18385                                    conflict_columns: conflict_columns_bitmap,
18386                                } => {
18387                                    let operation = operations
18388                                        .get(*op_index as usize)
18389                                        .map(CommitOperation::from);
18390                                    let (table, row_id) = operation
18391                                        .as_ref()
18392                                        .map(|op| (op.table.clone(), op.row_id.clone()))
18393                                        .unwrap_or_default();
18394                                    let server_row_json = self
18395                                        .schema
18396                                        .table(&table)
18397                                        .and_then(|t| {
18398                                            decode_row_bytes(t, server_row, &self.encryption)
18399                                                .ok()
18400                                                .map(|row| (t, row))
18401                                        })
18402                                        .map(|(t, row)| {
18403                                            let mut map = Map::new();
18404                                            for (i, column) in t.columns.iter().enumerate() {
18405                                                map.insert(
18406                                                    column.name.clone(),
18407                                                    column_value_to_json(
18408                                                        row.get(i).unwrap_or(&None),
18409                                                    ),
18410                                                );
18411                                            }
18412                                            map
18413                                        })
18414                                        .unwrap_or_default();
18415                                    // §6.3: decode the presence-layout bitmap
18416                                    // into the marked column names.
18417                                    let conflict_columns = self
18418                                        .schema
18419                                        .table(&table)
18420                                        .map(|t| {
18421                                            t.columns
18422                                                .iter()
18423                                                .enumerate()
18424                                                .filter(|(index, _)| {
18425                                                    conflict_columns_bitmap
18426                                                        .get(index / 8)
18427                                                        .is_some_and(|byte| {
18428                                                            byte & (1 << (index % 8)) != 0
18429                                                        })
18430                                                })
18431                                                .map(|(_, column)| column.name.clone())
18432                                                .collect::<Vec<_>>()
18433                                        })
18434                                        .unwrap_or_default();
18435                                    let conflict = ConflictRecord {
18436                                        client_commit_id: client_commit_id.to_owned(),
18437                                        op_index: *op_index,
18438                                        table,
18439                                        row_id,
18440                                        code: code.clone(),
18441                                        message: message.clone(),
18442                                        server_version: *server_version,
18443                                        server_row: server_row_json,
18444                                        conflict_columns,
18445                                        operation,
18446                                    };
18447                                    journal_results.push(CommitOperationOutcome::Conflict {
18448                                        conflict: conflict.clone(),
18449                                    });
18450                                    conflicts.push(conflict);
18451                                }
18452                                OpResult::Error {
18453                                    op_index,
18454                                    code,
18455                                    message,
18456                                    retryable,
18457                                } => {
18458                                    let rejection = RejectionRecord {
18459                                        client_commit_id: client_commit_id.to_owned(),
18460                                        op_index: *op_index,
18461                                        code: code.clone(),
18462                                        message: message.clone(),
18463                                        retryable: *retryable,
18464                                        details: rejection_details
18465                                            .and_then(|details| details.get(op_index))
18466                                            .cloned(),
18467                                        operation: operations
18468                                            .get(*op_index as usize)
18469                                            .map(CommitOperation::from),
18470                                    };
18471                                    journal_results.push(CommitOperationOutcome::Error {
18472                                        rejection: rejection.clone(),
18473                                    });
18474                                    rejections.push(rejection);
18475                                }
18476                            }
18477                        }
18478                        if self.retain_failed_commits {
18479                            self.retain_failed_commit(client_commit_id, &operations)?;
18480                        }
18481                        let outcome_status = if conflicts.is_empty() {
18482                            CommitOutcomeStatus::Rejected
18483                        } else {
18484                            CommitOutcomeStatus::Conflict
18485                        };
18486                        let persisted = self
18487                            .persist_commit_outcome(
18488                                client_commit_id,
18489                                outcome_status,
18490                                &journal_results,
18491                                Some(&operations),
18492                            )
18493                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18494                        persisted?;
18495                        batch.conflicts = !conflicts.is_empty();
18496                        batch.rejections = !rejections.is_empty();
18497                        batch.status = true;
18498                        batch.outcomes = true;
18499                        self.conflicts.extend(conflicts);
18500                        self.rejections.extend(rejections);
18501                        for operation in &operations {
18502                            self.overlay_dirty.table(&operation.table);
18503                        }
18504                    }
18505                }
18506                removed.push((index, self.outbox.remove(index)));
18507                if batch.status {
18508                    for operation in &operations {
18509                        if !self.record_row_scopes(
18510                            &mut batch,
18511                            &operation.table,
18512                            &operation.row_id,
18513                            false,
18514                        ) {
18515                            batch.table(&operation.table);
18516                        }
18517                    }
18518                }
18519                match status {
18520                    PushStatus::Applied | PushStatus::Cached => {
18521                        report.applied.push(client_commit_id.to_owned())
18522                    }
18523                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
18524                }
18525            }
18526            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)) {
18527                self.prune_commit_outcomes()?;
18528            }
18529            self.rebuild_overlay_if_dirty()?;
18530            self.finish_observation("syncular_push_results", batch)
18531        })();
18532        if let Err(error) = persisted {
18533            self.rollback_observation("syncular_push_results");
18534            self.failed_commits = previous_failed;
18535            self.sync_needed = previous_sync_needed;
18536            for (index, commit) in removed.into_iter().rev() {
18537                self.outbox.insert(index, commit);
18538            }
18539            self.conflicts.truncate(conflict_count);
18540            self.rejections.truncate(rejection_count);
18541            self.overlay_dirty.restore(was_dirty.clone());
18542            report.applied.truncate(applied_count);
18543            report.rejected.truncate(rejected_count);
18544            report.retryable.truncate(retryable_count);
18545            return Err(error);
18546        }
18547        if !previous_sync_needed && self.sync_needed {
18548            self.sync_intent_queue.push_back(SyncIntent::Interactive);
18549        }
18550        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
18551        Ok(())
18552    }
18553
18554    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
18555        status != PushStatus::Rejected
18556            || !results.iter().any(|result| {
18557                matches!(
18558                    result,
18559                    OpResult::Error {
18560                        code,
18561                        retryable: true,
18562                        ..
18563                    } if code == "sync.idempotency_cache_miss"
18564                )
18565            })
18566    }
18567
18568    // -- subscription sections ------------------------------------------------------
18569
18570    #[allow(clippy::too_many_arguments)]
18571    fn process_section(
18572        &mut self,
18573        transport: &mut dyn Transport,
18574        id: &str,
18575        status: SubStatus,
18576        reason_code: &str,
18577        effective_scopes: Vec<(String, Vec<String>)>,
18578        body: Vec<Frame>,
18579        sub_end: Option<(i64, Option<String>)>,
18580        meta: &RequestMeta,
18581        report: &mut SyncReport,
18582        coalesced_deletes: &HashSet<(i64, String, String)>,
18583    ) -> Result<(), SectionError> {
18584        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
18585            return Ok(()); // unknown echo: ignore
18586        };
18587        if status != SubStatus::Active && sub_end.is_none() {
18588            return Err(SectionError::Abort(
18589                "sync.invalid_request".into(),
18590                "subscription section without SUB_END".into(),
18591            ));
18592        }
18593        match status {
18594            SubStatus::Revoked => {
18595                self.begin_observation("syncular_revocation")
18596                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18597                let previous = self.subs[sub_index].clone();
18598                let was_dirty = self.overlay_dirty.snapshot();
18599                let previous_outbox = self.outbox.clone();
18600                let previous_failed = self.failed_commits.clone();
18601                let previous_rejections = self.rejections.clone();
18602                let previous_conflicts = self.conflicts.clone();
18603                let revoked_count = report.revoked.len();
18604                let failed_count = report.failed.len();
18605                let result = (|| {
18606                    let mut batch = ChangeAccumulator::default();
18607                    let registered = self.window_unit_by_sub_id(id);
18608                    // §3.3: stop pulling, purge exactly the last effective grant.
18609                    let (table, effective) = {
18610                        let sub = &self.subs[sub_index];
18611                        (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18612                    };
18613                    let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
18614                        SectionError::Abort(
18615                            "sync.unknown_table".to_owned(),
18616                            "revoked scope references an unknown table".to_owned(),
18617                        )
18618                    })?;
18619                    let dropped = self
18620                        .drop_failed_commits(|name, values| {
18621                            name == table
18622                                && !effective.is_empty()
18623                                && effective.iter().all(|(variable, allowed)| {
18624                                    scoped_table
18625                                        .scope_column(variable)
18626                                        .and_then(|column| values.get(column))
18627                                        .and_then(Value::as_str)
18628                                        .is_some_and(|value| {
18629                                            allowed.iter().any(|allowed| allowed == value)
18630                                        })
18631                                })
18632                        })
18633                        .map_err(|message| {
18634                            SectionError::Abort("storage.failed".to_owned(), message)
18635                        })?;
18636                    for commit in dropped {
18637                        batch.conflicts = true;
18638                        batch.rejections = true;
18639                        batch.outcomes = true;
18640                        for operation in commit.ops {
18641                            batch.table(&operation.table);
18642                        }
18643                    }
18644                    let purged = self.purge_scope_rows(&table, &effective);
18645                    match purged {
18646                        Ok(()) => {
18647                            self.record_scope_map(&mut batch, &table, &effective);
18648                            let sub = &mut self.subs[sub_index];
18649                            sub.state = SubState::Revoked;
18650                            sub.reason_code = Some(if reason_code.is_empty() {
18651                                "sync.scope_revoked".to_owned()
18652                            } else {
18653                                reason_code.to_owned()
18654                            });
18655                            report.revoked.push(id.to_owned());
18656                            let doomed_effective = effective;
18657                            let sub_table = table;
18658                            self.persist_sub(&self.subs[sub_index].clone())
18659                                .map_err(|message| {
18660                                    SectionError::Abort("storage.failed".into(), message)
18661                                })?;
18662                            let dropped = self
18663                                .drop_doomed_outbox(&sub_table, &doomed_effective)
18664                                .map_err(|message| {
18665                                    SectionError::Abort("storage.failed".to_owned(), message)
18666                                })?;
18667                            if dropped {
18668                                batch.status = true;
18669                                batch.rejections = true;
18670                                batch.outcomes = true;
18671                            }
18672                            // §5.9.7 B2: revocation deletes now-unauthorized blob
18673                            // bodies (evicted ≠ revoked).
18674                            self.delete_unreferenced_cached_blobs().map_err(|message| {
18675                                SectionError::Abort("storage.failed".into(), message)
18676                            })?;
18677                        }
18678                        Err(()) => {
18679                            // §3.3 fail closed: no local mapping — never clear by
18680                            // approximation; fatal configuration error.
18681                            let sub = &mut self.subs[sub_index];
18682                            sub.state = SubState::Failed;
18683                            sub.reason_code = Some("sync.scope_revoked".to_owned());
18684                            report.failed.push(id.to_owned());
18685                            self.persist_sub(&self.subs[sub_index].clone())
18686                                .map_err(|message| {
18687                                    SectionError::Abort("storage.failed".into(), message)
18688                                })?;
18689                        }
18690                    }
18691                    if let Some((base_key, unit)) = registered {
18692                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
18693                    }
18694                    self.rebuild_overlay_if_dirty()
18695                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18696                    self.finish_observation("syncular_revocation", batch)
18697                        .map_err(|message| {
18698                            SectionError::Abort("storage.failed".to_owned(), message)
18699                        })?;
18700                    Ok(())
18701                })();
18702                if result.is_err() {
18703                    self.rollback_observation("syncular_revocation");
18704                    self.subs[sub_index] = previous;
18705                    self.overlay_dirty.restore(was_dirty.clone());
18706                    self.outbox = previous_outbox;
18707                    self.failed_commits = previous_failed;
18708                    self.rejections = previous_rejections;
18709                    self.conflicts = previous_conflicts;
18710                    report.revoked.truncate(revoked_count);
18711                    report.failed.truncate(failed_count);
18712                }
18713                result
18714            }
18715            SubStatus::Reset => {
18716                self.begin_observation("syncular_reset")
18717                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18718                let previous = self.subs[sub_index].clone();
18719                let was_dirty = self.overlay_dirty.snapshot();
18720                let reset_count = report.resets.len();
18721                let result = (|| {
18722                    let mut batch = ChangeAccumulator::default();
18723                    let registered = self.window_unit_by_sub_id(id);
18724                    // §4.6: discard cursor + bootstrap state, keep local rows —
18725                    // reset is a staleness signal, not a purge signal.
18726                    let sub = &mut self.subs[sub_index];
18727                    sub.cursor = -1;
18728                    sub.bootstrap_state = None;
18729                    sub.reason_code = Some(if reason_code.is_empty() {
18730                        "sync.cursor_expired".to_owned()
18731                    } else {
18732                        reason_code.to_owned()
18733                    });
18734                    report.resets.push(id.to_owned());
18735                    self.persist_sub(&self.subs[sub_index].clone())
18736                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18737                    if let Some((base_key, unit)) = registered {
18738                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
18739                    }
18740                    self.finish_observation("syncular_reset", batch)
18741                        .map_err(|message| {
18742                            SectionError::Abort("storage.failed".to_owned(), message)
18743                        })?;
18744                    Ok(())
18745                })();
18746                if result.is_err() {
18747                    self.rollback_observation("syncular_reset");
18748                    self.subs[sub_index] = previous;
18749                    self.overlay_dirty.restore(was_dirty.clone());
18750                    report.resets.truncate(reset_count);
18751                }
18752                result
18753            }
18754            SubStatus::Active => {
18755                let fresh = meta
18756                    .fresh
18757                    .iter()
18758                    .find(|(fid, _)| fid == id)
18759                    .map(|(_, fresh)| *fresh)
18760                    .unwrap_or(false);
18761                let previous = self.subs[sub_index].clone();
18762                self.subs[sub_index].effective = Some(effective_scopes);
18763                let outcome = self
18764                    .apply_section_body(
18765                        transport,
18766                        sub_index,
18767                        body,
18768                        fresh,
18769                        meta,
18770                        report,
18771                        coalesced_deletes,
18772                    )
18773                    .and_then(|()| {
18774                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
18775                            SectionError::Abort(
18776                                "sync.invalid_request".into(),
18777                                "subscription section without SUB_END".into(),
18778                            )
18779                        })?;
18780                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
18781                            .map_err(|message| {
18782                                SectionError::Abort("storage.failed".into(), message)
18783                            })
18784                    });
18785                match outcome {
18786                    Ok(()) => {
18787                        if self.subs[sub_index].bootstrap_state.is_some() {
18788                            report.bootstrapping.push(id.to_owned());
18789                        }
18790                        Ok(())
18791                    }
18792                    Err(SectionError::FailClosed) => {
18793                        self.subs[sub_index] = previous.clone();
18794                        self.begin_observation("syncular_section_failure")
18795                            .map_err(|message| {
18796                                SectionError::Abort("storage.failed".into(), message)
18797                            })?;
18798                        let persisted = (|| {
18799                            let mut batch = ChangeAccumulator::default();
18800                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
18801                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
18802                            }
18803                            self.subs[sub_index].state = SubState::Failed;
18804                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
18805                            self.persist_sub(&self.subs[sub_index])?;
18806                            self.finish_observation("syncular_section_failure", batch)
18807                        })();
18808                        if let Err(message) = persisted {
18809                            self.rollback_observation("syncular_section_failure");
18810                            self.subs[sub_index] = previous;
18811                            return Err(SectionError::Abort("storage.failed".into(), message));
18812                        }
18813                        report.failed.push(id.to_owned());
18814                        Ok(())
18815                    }
18816                    Err(error) => {
18817                        // Frame/block transactions already completed. Preserve their
18818                        // rows and revisions, but leave this subscription cursor unchanged.
18819                        self.subs[sub_index] = previous;
18820                        Err(error)
18821                    }
18822                }
18823            }
18824        }
18825    }
18826
18827    fn apply_sub_end(
18828        &mut self,
18829        sub_index: usize,
18830        cursor: i64,
18831        bootstrap_state: Option<String>,
18832    ) -> Result<(), String> {
18833        #[cfg(feature = "bench-internals")]
18834        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
18835        let previous = self.subs[sub_index].clone();
18836        self.begin_observation("syncular_sub_end")?;
18837        let persisted = (|| {
18838            let mut batch = ChangeAccumulator::default();
18839            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
18840                && cursor >= 0
18841                && bootstrap_state.is_none();
18842            if completed {
18843                let table = self
18844                    .schema
18845                    .table(&previous.table)
18846                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
18847                let effective = previous.effective.as_deref().unwrap_or(&[]);
18848                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
18849                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18850                let rows = statement
18851                    .query_map(rusqlite::params![previous.table, cursor], |row| {
18852                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18853                    })
18854                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18855                let mut covered = HashSet::new();
18856                for row in rows {
18857                    let (id, intent) =
18858                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18859                    let values: Map<String, Value> = serde_json::from_str(&intent)
18860                        .map_err(|_| "sync.local_corrupt".to_owned())?;
18861                    if !effective.is_empty()
18862                        && effective.iter().all(|(variable, allowed)| {
18863                            table
18864                                .scope_column(variable)
18865                                .and_then(|column| values.get(column))
18866                                .is_some_and(|value| {
18867                                    allowed.contains(
18868                                        &value
18869                                            .as_str()
18870                                            .map_or_else(|| value.to_string(), str::to_owned),
18871                                    )
18872                                })
18873                        })
18874                    {
18875                        covered.insert(id);
18876                    }
18877                }
18878                drop(statement);
18879                for id in covered {
18880                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
18881                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18882                    self.overlay_dirty.table(&previous.table);
18883                    batch.table(&previous.table);
18884                }
18885                self.rebuild_overlay_if_dirty()?;
18886                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
18887                    batch.window(&base_key, &previous.table, &unit);
18888                }
18889            }
18890            let sub = &mut self.subs[sub_index];
18891            sub.cursor = cursor;
18892            sub.bootstrap_state = bootstrap_state;
18893            // §4.6: a completed (or continuing) active end clears the reset /
18894            // revocation reason, matching the TS core's active-state persist.
18895            sub.reason_code = None;
18896            sub.synced_once = true;
18897            self.persist_sub(&self.subs[sub_index])?;
18898            self.finish_observation("syncular_sub_end", batch)
18899        })();
18900        if persisted.is_err() {
18901            self.rollback_observation("syncular_sub_end");
18902            self.subs[sub_index] = previous;
18903        }
18904        persisted
18905    }
18906
18907    #[allow(clippy::too_many_arguments)]
18908    fn apply_section_body(
18909        &mut self,
18910        transport: &mut dyn Transport,
18911        sub_index: usize,
18912        body: Vec<Frame>,
18913        fresh: bool,
18914        meta: &RequestMeta,
18915        report: &mut SyncReport,
18916        coalesced_deletes: &HashSet<(i64, String, String)>,
18917    ) -> Result<(), SectionError> {
18918        let mut saw_segment = false;
18919        for frame in body {
18920            match frame {
18921                Frame::Commit {
18922                    tables,
18923                    changes,
18924                    commit_seq,
18925                    ..
18926                } => {
18927                    if self
18928                        .apply_commit_frame_coalesced(
18929                            &tables,
18930                            changes,
18931                            commit_seq,
18932                            coalesced_deletes,
18933                        )
18934                        .map_err(|(c, m)| SectionError::Abort(c, m))?
18935                    {
18936                        report.commits_applied += 1;
18937                    }
18938                }
18939                Frame::SegmentInline { payload } => {
18940                    let segment = decode_rows_segment(&payload)
18941                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
18942                    self.progress.update(|p| {
18943                        p.phase = ProgressPhase::Import;
18944                        p.subscription_id = Some(self.subs[sub_index].id.clone());
18945                        p.table = Some(self.subs[sub_index].table.clone());
18946                        p.segment_id = None;
18947                        p.bytes_received = payload.len() as u64;
18948                        p.bytes_total = Some(payload.len() as u64);
18949                        p.rows_processed = 0;
18950                        p.rows_total =
18951                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
18952                    });
18953                    let first = !saw_segment;
18954                    saw_segment = true;
18955                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18956                    report.segment_rows_applied += applied;
18957                }
18958                Frame::SegmentRef {
18959                    segment_id,
18960                    byte_length,
18961                    media_type,
18962                    table,
18963                    row_count,
18964                    as_of_commit_seq,
18965                    scope_digest,
18966                    row_cursor,
18967                    next_row_cursor,
18968                    url,
18969                    url_expires_at_ms,
18970                    ..
18971                } => {
18972                    // §4.2: reject a descriptor whose mediaType was not
18973                    // advertised — never skip or guess.
18974                    let advertised = match media_type {
18975                        MediaType::Rows => {
18976                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
18977                                || meta.accept & ACCEPT_INLINE_ROWS != 0
18978                        }
18979                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
18980                    };
18981                    if !advertised {
18982                        return Err(SectionError::Abort(
18983                            "sync.invalid_request".to_owned(),
18984                            format!(
18985                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
18986                                media_type.name()
18987                            ),
18988                        ));
18989                    }
18990                    self.progress.update(|p| {
18991                        p.phase = ProgressPhase::Download;
18992                        p.subscription_id = Some(self.subs[sub_index].id.clone());
18993                        p.table = Some(table.clone());
18994                        p.segment_id = Some(segment_id.clone());
18995                        p.bytes_received = 0;
18996                        p.bytes_total = u64::try_from(byte_length).ok();
18997                        p.rows_processed = 0;
18998                        p.rows_total = u64::try_from(row_count).ok();
18999                    });
19000                    let observer = self.progress.clone();
19001                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
19002                    let bytes = if let Some(url) = url {
19003                        // §5.4: a url-carrying descriptor MUST be fetched
19004                        // from that URL; failure invalidates the whole
19005                        // descriptor (no fall-through to §5.5 — re-pull
19006                        // recovers, §1.4 rule 5).
19007                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
19008                            return Err(SectionError::Abort(
19009                                "sync.invalid_request".to_owned(),
19010                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
19011                                    .to_owned(),
19012                            ));
19013                        }
19014                        // §5.4: MUST NOT start a fetch at/past expiry.
19015                        if url_expires_at_ms.is_some_and(|exp| {
19016                            exp <= transport
19017                                .segment_fetch_started_at(&url)
19018                                .unwrap_or_else(|| self.clock_now_ms())
19019                        }) {
19020                            return Err(SectionError::Abort(
19021                                "sync.segment_expired".to_owned(),
19022                                format!(
19023                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
19024                                ),
19025                            ));
19026                        }
19027                        transport
19028                            .fetch_url(&url, &mut on_progress)
19029                            .map_err(|e| self.segment_fetch_failed(e))?
19030                    } else {
19031                        let requested_scopes_json =
19032                            canonical_scope_json(&self.subs[sub_index].requested);
19033                        transport
19034                            .download_segment(
19035                                &SegmentRequest {
19036                                    segment_id: segment_id.clone(),
19037                                    table,
19038                                    requested_scopes_json,
19039                                },
19040                                &mut on_progress,
19041                            )
19042                            .map_err(|e| self.segment_fetch_failed(e))?
19043                    };
19044                    // §5.1: verify the content address before applying.
19045                    on_progress(bytes.len() as u64);
19046                    let digest = Sha256::digest(&bytes);
19047                    let expected = segment_id
19048                        .strip_prefix("sha256:")
19049                        .unwrap_or(segment_id.as_str());
19050                    if bytes_to_hex(&digest) != expected {
19051                        // §5.1: the client discards the segment and re-pulls.
19052                        self.schedule_background_retry();
19053                        return Err(SectionError::Abort(
19054                            "sync.invalid_request".to_owned(),
19055                            "segment bytes do not match the content address (§5.1)".to_owned(),
19056                        ));
19057                    }
19058                    self.progress.update(|p| p.phase = ProgressPhase::Import);
19059                    if media_type == MediaType::Sqlite {
19060                        // §5.3: images are whole-table — a paged sqlite
19061                        // descriptor is invalid.
19062                        if row_cursor.is_some() || next_row_cursor.is_some() {
19063                            return Err(SectionError::Abort(
19064                                "sync.invalid_request".to_owned(),
19065                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
19066                                    .to_owned(),
19067                            ));
19068                        }
19069                        let first = !saw_segment;
19070                        saw_segment = true;
19071                        let applied = self.apply_sqlite_segment(
19072                            sub_index,
19073                            &bytes,
19074                            fresh && first,
19075                            row_count,
19076                            as_of_commit_seq,
19077                            &scope_digest,
19078                        )?;
19079                        report.segment_rows_applied += applied;
19080                    } else {
19081                        let segment = decode_rows_segment(&bytes).map_err(|e| {
19082                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
19083                        })?;
19084                        let first = row_cursor.is_none();
19085                        saw_segment = true;
19086                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
19087                        report.segment_rows_applied += applied;
19088                    }
19089                }
19090                Frame::Unknown { .. } => {}
19091                Frame::Error { code, message, .. } => {
19092                    return Err(SectionError::Abort(code, message))
19093                }
19094                _ => {
19095                    return Err(SectionError::Abort(
19096                        "sync.invalid_request".to_owned(),
19097                        "unexpected frame inside a subscription section".to_owned(),
19098                    ));
19099                }
19100            }
19101        }
19102        Ok(())
19103    }
19104
19105    /// Apply one commit frame after dropping a scope move's duplicate delete
19106    /// when the same commit carries the row's upsert in another applied
19107    /// section (§2.2). A frame that carried both itself is untouched.
19108    fn apply_commit_frame_coalesced(
19109        &mut self,
19110        tables: &[String],
19111        changes: Vec<ssp2::model::Change>,
19112        commit_seq: i64,
19113        coalesced_deletes: &HashSet<(i64, String, String)>,
19114    ) -> Result<bool, (String, String)> {
19115        let changes: Vec<ssp2::model::Change> = changes
19116            .into_iter()
19117            .filter(|change| {
19118                if change.op != Op::Delete {
19119                    return true;
19120                }
19121                let Some(table) = tables.get(change.table_index as usize) else {
19122                    return true;
19123                };
19124                !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
19125            })
19126            .collect();
19127        if changes.is_empty() {
19128            return Ok(false);
19129        }
19130        self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
19131        Ok(true)
19132    }
19133
19134    // Both pull and realtime frames use the same durable observation boundary.
19135    fn apply_commit_frame(
19136        &mut self,
19137        tables: &[String],
19138        changes: &[ssp2::model::Change],
19139        commit_seq: Option<i64>,
19140    ) -> Result<(), (String, String)> {
19141        #[cfg(feature = "bench-internals")]
19142        let _phase = self.benchmark_phases.start(Phase::CommitApply);
19143        let was_dirty = self.overlay_dirty.snapshot();
19144        self.coalesce_unit(changes.len())
19145            .and_then(|()| self.begin_observation("syncular_commit"))
19146            .map_err(|message| ("storage.failed".into(), message))?;
19147        let applied = (|| {
19148            let mut batch = ChangeAccumulator::default();
19149            self.record_commit_changes(&mut batch, tables, changes);
19150            if let Some(sequence) = commit_seq {
19151                for change in changes {
19152                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
19153                        (
19154                            "sync.invalid_request".to_owned(),
19155                            "change tableIndex out of range".to_owned(),
19156                        )
19157                    })?;
19158                    if self
19159                        .outbox
19160                        .iter()
19161                        .flat_map(|commit| &commit.ops)
19162                        .any(|op| op.table == *table && op.row_id == change.row_id)
19163                    {
19164                        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])
19165                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
19166                    }
19167                    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])
19168                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
19169                }
19170            }
19171            self.apply_commit_changes(tables, changes)?;
19172            self.rebuild_overlay_if_dirty()
19173                .map_err(|message| ("storage.failed".into(), message))?;
19174            self.finish_observation("syncular_commit", batch)
19175                .map_err(|message| ("storage.failed".into(), message))
19176        })();
19177        if applied.is_err() {
19178            self.rollback_observation("syncular_commit");
19179            self.overlay_dirty.restore(was_dirty.clone());
19180        }
19181        applied
19182    }
19183
19184    fn apply_commit_changes(
19185        &mut self,
19186        tables: &[String],
19187        changes: &[ssp2::model::Change],
19188    ) -> Result<(), (String, String)> {
19189        // A clean overlay can reconcile changed primary keys independently
19190        // when their tables have no secondary unique constraints. Such a
19191        // constraint can change whether another row's pending write succeeds,
19192        // so those frames still require the complete FIFO replay below.
19193        let mirror_visible = !self.overlay_dirty.get()
19194            && self.failed_commits.is_empty()
19195            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
19196                || changes.iter().all(|change| {
19197                    tables
19198                        .get(change.table_index as usize)
19199                        .and_then(|name| self.schema.table(name))
19200                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
19201                }));
19202        for change in changes {
19203            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
19204                (
19205                    "sync.invalid_request".to_owned(),
19206                    "change tableIndex out of range".to_owned(),
19207                )
19208            })?;
19209            let table = self.schema.table(table_name).ok_or_else(|| {
19210                (
19211                    "sync.schema_mismatch".to_owned(),
19212                    format!("change targets unknown table {table_name:?}"),
19213                )
19214            })?;
19215            match change.op {
19216                Op::Upsert => {
19217                    let payload = change.row.as_ref().ok_or_else(|| {
19218                        (
19219                            "sync.invalid_request".to_owned(),
19220                            "upsert change without row payload".to_owned(),
19221                        )
19222                    })?;
19223                    // §5.11: decrypt encrypted columns on apply.
19224                    let row = {
19225                        #[cfg(feature = "bench-internals")]
19226                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
19227                        decode_row_bytes(table, payload, &self.encryption)
19228                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
19229                    };
19230                    let version = change.row_version.unwrap_or(0);
19231                    let table_name = table.name.clone();
19232                    self.write_base_row(&table_name, &row, version)
19233                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19234                    if mirror_visible {
19235                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
19236                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19237                    }
19238                }
19239                Op::Delete => {
19240                    self.delete_base_row(table_name, &change.row_id)
19241                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19242                    if mirror_visible {
19243                        let sql = format!(
19244                            "DELETE FROM {} WHERE {}",
19245                            visible_table(table_name),
19246                            row_id_predicate(table)
19247                        );
19248                        self.conn
19249                            .prepare_cached(&sql)
19250                            .and_then(|mut statement| {
19251                                statement.execute(rusqlite::params![change.row_id])
19252                            })
19253                            .map_err(|error| {
19254                                ("sync.invalid_request".to_owned(), error.to_string())
19255                            })?;
19256                    }
19257                }
19258            }
19259        }
19260        if mirror_visible {
19261            {
19262                let changed_rows: HashSet<(&str, &str)> = changes
19263                    .iter()
19264                    .map(|change| {
19265                        (
19266                            tables[change.table_index as usize].as_str(),
19267                            change.row_id.as_str(),
19268                        )
19269                    })
19270                    .collect();
19271                let acknowledged = self
19272                    .acknowledged_operations(Some(&changed_rows), None)
19273                    .map_err(|error| ("storage.failed".to_owned(), error))?;
19274                self.apply_outbox_ops(&acknowledged)
19275                    .map_err(|message| ("storage.failed".into(), message))?;
19276                self.apply_outbox_ops(
19277                    self.outbox
19278                        .iter()
19279                        .flat_map(|commit| &commit.ops)
19280                        .filter(|op| {
19281                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
19282                        }),
19283                )
19284                .map_err(|message| ("storage.failed".into(), message))?;
19285            }
19286            self.overlay_dirty.set(false);
19287        }
19288        Ok(())
19289    }
19290
19291    /// §5.6 segment application: validate against the generated schema,
19292    /// clear the grant on a fresh bootstrap's first page (fail closed
19293    /// without a mapping), then replace-or-upsert each row with its
19294    /// segment-carried server version (§5.2).
19295    fn apply_segment(
19296        &mut self,
19297        sub_index: usize,
19298        segment: &RowsSegment,
19299        first_fresh_page: bool,
19300    ) -> Result<u32, SectionError> {
19301        let (sub_table, effective) = {
19302            let sub = &self.subs[sub_index];
19303            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19304        };
19305        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19306            SectionError::Abort(
19307                "sync.schema_mismatch".to_owned(),
19308                format!("subscription table {sub_table:?} is not in the client schema"),
19309            )
19310        })?;
19311        // §5.2: the column table validates against the generated schema —
19312        // order, names, types, nullability; mismatch is fatal. §5.11: the
19313        // server sends the WIRE types (bytes for an encrypted column), so
19314        // validate against wire_columns.
19315        let matches = segment.table == table.name
19316            && segment.schema_version == self.schema.version
19317            && segment.columns.len() == table.wire_columns.len()
19318            && segment
19319                .columns
19320                .iter()
19321                .zip(table.wire_columns.iter())
19322                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
19323        if !matches {
19324            return Err(SectionError::Abort(
19325                "sync.schema_mismatch".to_owned(),
19326                "segment column table does not match the generated schema (§5.2)".to_owned(),
19327            ));
19328        }
19329        let mut applied = 0u32;
19330        let empty = Vec::new();
19331        let blocks = if segment.blocks.is_empty() {
19332            std::slice::from_ref(&empty)
19333        } else {
19334            &segment.blocks
19335        };
19336        for (index, block) in blocks.iter().enumerate() {
19337            let clear = first_fresh_page && index == 0;
19338            if !clear && block.is_empty() {
19339                continue;
19340            }
19341            let was_dirty = self.overlay_dirty.snapshot();
19342            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19343                && self
19344                    .outbox
19345                    .iter()
19346                    .flat_map(|commit| &commit.ops)
19347                    .any(|op| op.table == table.name);
19348            let mirror_visible = !reconcile_pending
19349                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19350            self.coalesce_unit(block.len())
19351                .and_then(|()| self.begin_observation("syncular_segment_block"))
19352                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19353            let outcome = (|| {
19354                self.rebuild_overlay_if_dirty()
19355                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19356                let mut changed_keys = Vec::new();
19357                let mut batch = ChangeAccumulator::default();
19358                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
19359                    batch.table(&table.name);
19360                }
19361                if clear {
19362                    self.purge_scope_rows(&table.name, &effective)
19363                        .map_err(|()| SectionError::FailClosed)?;
19364                }
19365                for (row_index, row) in block.iter().enumerate() {
19366                    let decrypted;
19367                    let values = if table.has_encrypted_columns() {
19368                        let mut values = row.values.clone();
19369                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
19370                            .map_err(|message| {
19371                            SectionError::Abort("client.decrypt_failed".into(), message)
19372                        })?;
19373                        decrypted = values;
19374                        &decrypted
19375                    } else {
19376                        &row.values
19377                    };
19378                    self.write_base_row(&table.name, values, row.server_version)
19379                        .map_err(|message| {
19380                            SectionError::Abort("sync.invalid_request".into(), message)
19381                        })?;
19382                    if reconcile_pending {
19383                        changed_keys.push(
19384                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
19385                                |message| SectionError::Abort("storage.failed".into(), message),
19386                            )?,
19387                        );
19388                    }
19389                    if mirror_visible {
19390                        self.write_row(
19391                            &visible_table(&table.name),
19392                            &table.name,
19393                            values,
19394                            row.server_version,
19395                        )
19396                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19397                    }
19398                    let processed = u64::from(applied) + row_index as u64 + 1;
19399                    if processed.is_multiple_of(1024) {
19400                        self.progress.update(|p| p.rows_processed = processed);
19401                    }
19402                }
19403                if reconcile_pending {
19404                    self.reconcile_imported_rows(&table, &mut changed_keys)
19405                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19406                }
19407                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19408                    self.apply_outbox_ops(
19409                        self.outbox
19410                            .iter()
19411                            .flat_map(|commit| &commit.ops)
19412                            .filter(|op| op.table == table.name),
19413                    )
19414                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19415                    self.overlay_dirty.set(false);
19416                } else {
19417                    self.rebuild_overlay_if_dirty()
19418                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19419                }
19420                self.finish_observation("syncular_segment_block", batch)
19421                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
19422            })();
19423            if let Err(error) = outcome {
19424                self.rollback_observation("syncular_segment_block");
19425                self.overlay_dirty.restore(was_dirty.clone());
19426                return Err(error);
19427            }
19428            applied += block.len() as u32;
19429            self.progress
19430                .update(|p| p.rows_processed = u64::from(applied));
19431            std::thread::yield_now();
19432        }
19433        self.progress
19434            .update(|p| p.rows_processed = u64::from(applied));
19435        Ok(applied)
19436    }
19437
19438    /// §5.3 sqlite-image application: validate the in-file metadata
19439    /// against the descriptor, validate column names/order against the
19440    /// generated schema, run the §5.6 first-page clear when fresh, then
19441    /// replace-or-upsert every image row with its `_syncular_version`.
19442    /// Mechanics: the image lands in a temp file read through a second
19443    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
19444    /// Each image chunk commits its writes and revision before readers continue.
19445    fn apply_sqlite_segment(
19446        &mut self,
19447        sub_index: usize,
19448        bytes: &[u8],
19449        first_fresh_page: bool,
19450        row_count: i64,
19451        as_of_commit_seq: i64,
19452        scope_digest: &str,
19453    ) -> Result<u32, SectionError> {
19454        let invalid = |detail: &str| {
19455            SectionError::Abort(
19456                "sync.invalid_request".to_owned(),
19457                format!("sqlite segment rejected: {detail} (§5.3)"),
19458            )
19459        };
19460        let (sub_table, effective) = {
19461            let sub = &self.subs[sub_index];
19462            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19463        };
19464        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19465            SectionError::Abort(
19466                "sync.schema_mismatch".to_owned(),
19467                format!("subscription table {sub_table:?} is not in the client schema"),
19468            )
19469        })?;
19470
19471        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
19472        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
19473        let img = match rusqlite::Connection::open_with_flags(
19474            &path,
19475            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
19476        ) {
19477            Ok(conn) => conn,
19478            Err(_) => {
19479                let _ = std::fs::remove_file(&path);
19480                return Err(invalid("bytes do not open as a SQLite database"));
19481            }
19482        };
19483        let outcome = self.apply_sqlite_image(
19484            &img,
19485            &table,
19486            first_fresh_page,
19487            &effective,
19488            row_count,
19489            as_of_commit_seq,
19490            scope_digest,
19491        );
19492        drop(img);
19493        let _ = std::fs::remove_file(&path);
19494        outcome
19495    }
19496
19497    #[allow(clippy::too_many_arguments)]
19498    fn apply_sqlite_image(
19499        &mut self,
19500        img: &rusqlite::Connection,
19501        table: &crate::schema::TableSchema,
19502        first_fresh_page: bool,
19503        effective: &[(String, Vec<String>)],
19504        row_count: i64,
19505        as_of_commit_seq: i64,
19506        scope_digest: &str,
19507    ) -> Result<u32, SectionError> {
19508        let invalid = |detail: String| {
19509            SectionError::Abort(
19510                "sync.invalid_request".to_owned(),
19511                format!("sqlite segment rejected: {detail} (§5.3)"),
19512            )
19513        };
19514
19515        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
19516        //    one row).
19517        type MetaRow = (i64, String, i64, i64, String, i64, i64);
19518        let meta: MetaRow = img
19519            .query_row(
19520                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
19521                        \"scopeDigest\", \"rowCount\",
19522                        (SELECT count(*) FROM _syncular_segment)
19523                 FROM _syncular_segment",
19524                [],
19525                |row| {
19526                    Ok((
19527                        row.get(0)?,
19528                        row.get(1)?,
19529                        row.get(2)?,
19530                        row.get(3)?,
19531                        row.get(4)?,
19532                        row.get(5)?,
19533                        row.get(6)?,
19534                    ))
19535                },
19536            )
19537            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
19538        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
19539        if meta_count != 1 {
19540            return Err(invalid(format!(
19541                "_syncular_segment must contain exactly one row, found {meta_count}"
19542            )));
19543        }
19544        if format != 1 {
19545            return Err(invalid(format!("format {format}")));
19546        }
19547        if meta_table != table.name {
19548            return Err(invalid(format!("image table {meta_table:?}")));
19549        }
19550        if schema_version != i64::from(self.schema.version) {
19551            return Err(invalid(format!("schemaVersion {schema_version}")));
19552        }
19553        if pin != as_of_commit_seq {
19554            return Err(invalid(format!("asOfCommitSeq {pin}")));
19555        }
19556        if digest != scope_digest {
19557            return Err(invalid("scopeDigest mismatch".to_owned()));
19558        }
19559        if meta_rows != row_count {
19560            return Err(invalid(format!("rowCount {meta_rows}")));
19561        }
19562
19563        // 2. Column names and order vs the generated schema (§5.3 rule 3).
19564        let mut names: Vec<String> = Vec::new();
19565        let mut primary_keys: Vec<String> = Vec::new();
19566        {
19567            let mut stmt = img
19568                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
19569                .map_err(|_| invalid("image data table missing".to_owned()))?;
19570            let mut rows = stmt
19571                .query([])
19572                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
19573            while let Some(row) = rows
19574                .next()
19575                .map_err(|_| invalid("image data table unreadable".to_owned()))?
19576            {
19577                if row
19578                    .get::<_, i64>(5)
19579                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
19580                    > 0
19581                {
19582                    primary_keys.push(row.get(1).map_err(|error| {
19583                        invalid(Self::sqlite_failure(&self.storage_failure, error))
19584                    })?);
19585                }
19586                names.push(
19587                    row.get::<_, String>(1)
19588                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
19589                );
19590            }
19591        }
19592        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
19593        expected.push("_syncular_version");
19594        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
19595            return Err(SectionError::Abort(
19596                "sync.schema_mismatch".to_owned(),
19597                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
19598            ));
19599        }
19600
19601        if primary_keys != vec![table.primary_key.clone()] {
19602            return Err(invalid(
19603                "image primary key does not match generated schema".into(),
19604            ));
19605        }
19606        let actual_count: i64 = img
19607            .query_row(
19608                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
19609                [],
19610                |row| row.get(0),
19611            )
19612            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19613        if actual_count != row_count {
19614            return Err(invalid("image row count does not match descriptor".into()));
19615        }
19616        let insert = self.insert_row_sql(&base_table(&table.name), table);
19617        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
19618        let column_list = names
19619            .iter()
19620            .map(|n| quote_ident(n))
19621            .collect::<Vec<_>>()
19622            .join(", ");
19623        let mut stmt = img
19624            .prepare(&format!(
19625                "SELECT {column_list} FROM {} ORDER BY {}",
19626                quote_ident(&table.name),
19627                quote_ident(&table.primary_key)
19628            ))
19629            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19630        let mut rows = stmt
19631            .query([])
19632            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19633        let mut applied = 0u32;
19634        loop {
19635            let clear = applied == 0 && first_fresh_page;
19636            let was_dirty = self.overlay_dirty.snapshot();
19637            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19638                && self
19639                    .outbox
19640                    .iter()
19641                    .flat_map(|commit| &commit.ops)
19642                    .any(|op| op.table == table.name);
19643            let mirror_visible = !reconcile_pending
19644                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19645            // A chunk holds up to 1,024 rows (§5.3).
19646            self.coalesce_unit(1024)
19647                .and_then(|()| self.begin_observation("syncular_image_chunk"))
19648                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19649            let result = (|| {
19650                self.rebuild_overlay_if_dirty()
19651                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19652                let mut changed_keys = Vec::new();
19653                let mut batch = ChangeAccumulator::default();
19654                if clear {
19655                    if self.scoped_rows_exist(&table.name, effective) {
19656                        batch.table(&table.name);
19657                    }
19658                    self.purge_scope_rows(&table.name, effective)
19659                        .map_err(|()| SectionError::FailClosed)?;
19660                }
19661                let mut processed = 0u32;
19662                {
19663                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
19664                        invalid(Self::sqlite_failure(&self.storage_failure, error))
19665                    })?;
19666                    let mut mirror = if mirror_visible {
19667                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
19668                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19669                        })?)
19670                    } else {
19671                        None
19672                    };
19673                    for _ in 0..1024 {
19674                        let Some(row) = rows.next().map_err(|error| {
19675                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19676                        })?
19677                        else {
19678                            break;
19679                        };
19680                        for (i, column) in table.columns.iter().enumerate() {
19681                            let cell = row.get_ref(i).map_err(|error| {
19682                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19683                            })?;
19684                            let param = image_cell_param(column, cell).map_err(invalid)?;
19685                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
19686                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19687                            })?;
19688                            if let Some(mirror) = &mut mirror {
19689                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
19690                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
19691                                })?;
19692                            }
19693                        }
19694                        let version_index = table.columns.len();
19695                        let version: i64 = row.get(version_index).map_err(|error| {
19696                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19697                        })?;
19698                        if version < 1 {
19699                            return Err(invalid("image row version must be positive".into()));
19700                        }
19701                        ins.raw_bind_parameter(version_index + 1, version)
19702                            .map_err(|error| {
19703                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19704                            })?;
19705                        ins.raw_execute().map_err(|error| {
19706                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19707                        })?;
19708                        if let Some(mirror) = &mut mirror {
19709                            mirror
19710                                .raw_bind_parameter(version_index + 1, version)
19711                                .map_err(|error| {
19712                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
19713                                })?;
19714                            mirror.raw_execute().map_err(|error| {
19715                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19716                            })?;
19717                        }
19718                        if reconcile_pending {
19719                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
19720                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
19721                            )?);
19722                        }
19723                        processed += 1;
19724                    }
19725                }
19726                if processed > 0 {
19727                    batch.table(&table.name);
19728                    self.overlay_dirty.table(&table.name);
19729                }
19730                if reconcile_pending {
19731                    self.reconcile_imported_rows(table, &mut changed_keys)
19732                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19733                }
19734                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19735                    self.apply_outbox_ops(
19736                        self.outbox
19737                            .iter()
19738                            .flat_map(|commit| &commit.ops)
19739                            .filter(|op| op.table == table.name),
19740                    )
19741                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19742                    self.overlay_dirty.set(false);
19743                } else {
19744                    self.rebuild_overlay_if_dirty()
19745                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19746                }
19747                self.finish_observation("syncular_image_chunk", batch)
19748                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19749                Ok(processed)
19750            })();
19751            let processed = match result {
19752                Ok(processed) => processed,
19753                Err(error) => {
19754                    self.rollback_observation("syncular_image_chunk");
19755                    self.overlay_dirty.restore(was_dirty.clone());
19756                    return Err(error);
19757                }
19758            };
19759            applied += processed;
19760            self.progress
19761                .update(|p| p.rows_processed = u64::from(applied));
19762            std::thread::yield_now();
19763            if i64::from(applied) == actual_count {
19764                break;
19765            }
19766        }
19767        Ok(applied)
19768    }
19769
19770    /// Restore only imported and pending row identities before FIFO replay. All pending
19771    /// identities participate: a server row can free a unique value for a different edit.
19772    /// The caller owns the chunk transaction and publishes its revision after replay.
19773    fn reconcile_imported_rows(
19774        &self,
19775        table: &TableSchema,
19776        keys: &mut Vec<SqlValue>,
19777    ) -> Result<(), String> {
19778        let visible = visible_table(&table.name);
19779        let base = base_table(&table.name);
19780        let pk = quote_ident(&table.primary_key);
19781        let predicate = row_id_predicate(table);
19782        let mut lookup = self.conn.prepare_cached(&format!(
19783            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
19784        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19785        let mut deleted = HashSet::new();
19786        for op in self
19787            .outbox
19788            .iter()
19789            .flat_map(|commit| &commit.ops)
19790            .filter(|op| op.table == table.name)
19791        {
19792            if op.upsert {
19793                if let Some(values) = &op.values {
19794                    let key = json_to_column_value(
19795                        &table.columns[table.pk_index],
19796                        values.get(&table.primary_key),
19797                    )?;
19798                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
19799                }
19800            } else if deleted.insert(&op.row_id) {
19801                keys.extend(
19802                    lookup
19803                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
19804                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19805                        .collect::<Result<Vec<_>, _>>()
19806                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19807                );
19808            }
19809        }
19810        // Delete the complete affected set before restoring any base value, so
19811        // old optimistic unique values cannot collide with the new server rows.
19812        for chunk in keys.chunks(400) {
19813            let holes = vec!["?"; chunk.len()].join(",");
19814            self.conn
19815                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
19816                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19817                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19818        }
19819        let updates = table
19820            .columns
19821            .iter()
19822            .map(|column| column.name.as_str())
19823            .chain(std::iter::once("_syncular_version"))
19824            .filter(|name| *name != table.primary_key)
19825            .map(|name| {
19826                let name = quote_ident(name);
19827                format!("{name}=excluded.{name}")
19828            })
19829            .collect::<Vec<_>>()
19830            .join(",");
19831        for chunk in keys.chunks(400) {
19832            let holes = vec!["?"; chunk.len()].join(",");
19833            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
19834                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19835                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19836        }
19837        Ok(())
19838    }
19839
19840    // -- application-authorized local purge ---------------------------------------
19841
19842    fn compile_local_data_purge(
19843        &self,
19844        input: &LocalDataPurgeInput,
19845    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
19846        let invalid = |message: String| format!("sync.invalid_request: {message}");
19847        if input.purge_id.is_empty()
19848            || input.purge_id.len() > 128
19849            || !is_local_operation_code_like(&input.purge_id)
19850        {
19851            return Err(invalid(
19852                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
19853            ));
19854        }
19855        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
19856            return Err(invalid(format!(
19857                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
19858            )));
19859        }
19860
19861        let mut deduplicated: BTreeMap<
19862            String,
19863            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
19864        > = BTreeMap::new();
19865        for target in &input.targets {
19866            let table = self.schema.table(&target.table).ok_or_else(|| {
19867                invalid(format!(
19868                    "local purge names unknown table {:?}",
19869                    target.table
19870                ))
19871            })?;
19872            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
19873                return Err(invalid(format!(
19874                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
19875                    target.table
19876                )));
19877            }
19878            let mut selectors = Vec::with_capacity(target.selectors.len());
19879            let mut canonical_selectors = BTreeMap::new();
19880            for (column_name, raw_values) in &target.selectors {
19881                let Some((column_index, column)) = table
19882                    .columns
19883                    .iter()
19884                    .enumerate()
19885                    .find(|(_, column)| column.name == *column_name)
19886                else {
19887                    return Err(invalid(format!(
19888                        "local purge target {:?} names unknown column {:?}",
19889                        target.table, column_name
19890                    )));
19891                };
19892                let encrypted = table
19893                    .encrypted_columns
19894                    .iter()
19895                    .any(|candidate| candidate.index == column_index);
19896                if column.ty != ColumnType::String || encrypted {
19897                    return Err(invalid(format!(
19898                        "local purge selector {:?}.{:?} must be a plaintext string column",
19899                        target.table, column_name
19900                    )));
19901                }
19902                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
19903                    return Err(invalid(format!(
19904                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
19905                        target.table, column_name
19906                    )));
19907                }
19908                let mut values = raw_values.clone();
19909                values.sort();
19910                values.dedup();
19911                if values.iter().any(|value| {
19912                    value.is_empty()
19913                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
19914                        || !is_local_operation_code_like(value)
19915                }) {
19916                    return Err(invalid(format!(
19917                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
19918                    )));
19919                }
19920                selectors.push((column_name.clone(), values.clone()));
19921                canonical_selectors.insert(column_name.clone(), values);
19922            }
19923            let canonical = LocalDataPurgeTarget {
19924                table: target.table.clone(),
19925                selectors: canonical_selectors,
19926            };
19927            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19928            deduplicated.insert(
19929                key,
19930                (
19931                    CompiledLocalDataPurgeTarget {
19932                        table: target.table.clone(),
19933                        selectors,
19934                    },
19935                    canonical,
19936                ),
19937            );
19938        }
19939        let targets = deduplicated
19940            .values()
19941            .map(|(compiled, _)| compiled.clone())
19942            .collect::<Vec<_>>();
19943        let canonical = deduplicated
19944            .values()
19945            .map(|(_, target)| target.clone())
19946            .collect::<Vec<_>>();
19947        let canonical_plan =
19948            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19949        Ok((targets, canonical_plan))
19950    }
19951
19952    fn local_purge_base_row_ids(
19953        &self,
19954        targets: &[CompiledLocalDataPurgeTarget],
19955    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
19956        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
19957        for target in targets {
19958            let table = self
19959                .schema
19960                .table(&target.table)
19961                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
19962            let mut clauses = Vec::with_capacity(target.selectors.len());
19963            let mut params = Vec::new();
19964            for (column, values) in &target.selectors {
19965                clauses.push(format!(
19966                    "{} IN ({})",
19967                    quote_ident(column),
19968                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
19969                ));
19970                params.extend(values.iter().cloned().map(SqlValue::Text));
19971            }
19972            let sql = format!(
19973                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
19974                quote_ident(&table.primary_key),
19975                base_table(&target.table),
19976                clauses.join(" AND ")
19977            );
19978            let mut statement = self
19979                .conn
19980                .prepare(&sql)
19981                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19982            let rows = statement
19983                .query_map(rusqlite::params_from_iter(params), |row| {
19984                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
19985                    render_row_id_json(Some(&value))
19986                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
19987                })
19988                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19989            let ids = by_table.entry(target.table.clone()).or_default();
19990            for row in rows {
19991                ids.insert(
19992                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19993                );
19994            }
19995        }
19996        Ok(by_table)
19997    }
19998
19999    fn local_purge_values_match(
20000        target: &CompiledLocalDataPurgeTarget,
20001        values: &Map<String, Value>,
20002    ) -> bool {
20003        target.selectors.iter().all(|(column, allowed)| {
20004            values
20005                .get(column)
20006                .and_then(Value::as_str)
20007                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
20008        })
20009    }
20010
20011    /// Apply one host-authorized local security purge. The host MUST gate the
20012    /// corresponding subscriptions first; this primitive owns local SQLite
20013    /// cleanup and never grants/revokes server authority by itself.
20014    pub fn purge_local_data(
20015        &mut self,
20016        input: &LocalDataPurgeInput,
20017    ) -> Result<LocalDataPurgeResult, String> {
20018        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
20019        let meta_key = format!("localPurge:{}", input.purge_id);
20020        let applied_plan = self
20021            .conn
20022            .query_row(
20023                "SELECT value FROM _syncular_meta WHERE key = ?1",
20024                rusqlite::params![meta_key],
20025                |row| row.get::<_, String>(0),
20026            )
20027            .optional()
20028            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20029        if let Some(applied_plan) = applied_plan {
20030            if applied_plan != canonical_plan {
20031                return Err(format!(
20032                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
20033                    input.purge_id
20034                ));
20035            }
20036            return Ok(LocalDataPurgeResult {
20037                already_applied: true,
20038                purged_rows: 0,
20039                dropped_commits: 0,
20040            });
20041        }
20042
20043        // RFC 0005 D7: the container file cannot be a purge target (its name is
20044        // not in the running schema), so purge drops it as a fixed step, before
20045        // any mirror row is touched and unconditionally.
20046        let _ = self.drop_previous_version();
20047
20048        let prior_outbox = self.outbox.clone();
20049        let prior_failed = self.failed_commits.clone();
20050        let prior_rejections = self.rejections.clone();
20051        let prior_conflicts = self.conflicts.clone();
20052        let prior_overlay_dirty = self.overlay_dirty.snapshot();
20053        self.begin_observation("syncular_local_purge")?;
20054        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
20055            let row_ids = self.local_purge_base_row_ids(&targets)?;
20056            let retained_dropped = self.drop_failed_commits(|table, values| {
20057                targets.iter().any(|target| {
20058                    target.table == table && Self::local_purge_values_match(target, values)
20059                })
20060            })?;
20061            let doomed = self
20062                .outbox
20063                .iter()
20064                .filter(|commit| {
20065                    commit.ops.iter().any(|operation| {
20066                        let matching_targets = targets
20067                            .iter()
20068                            .filter(|target| target.table == operation.table)
20069                            .collect::<Vec<_>>();
20070                        if matching_targets.is_empty() {
20071                            return false;
20072                        }
20073                        if row_ids
20074                            .get(&operation.table)
20075                            .is_some_and(|ids| ids.contains(&operation.row_id))
20076                        {
20077                            return true;
20078                        }
20079                        operation.values.as_ref().is_some_and(|values| {
20080                            matching_targets
20081                                .iter()
20082                                .any(|target| Self::local_purge_values_match(target, values))
20083                        })
20084                    })
20085                })
20086                .cloned()
20087                .collect::<Vec<_>>();
20088            let doomed_ids = doomed
20089                .iter()
20090                .map(|commit| commit.client_commit_id.clone())
20091                .collect::<BTreeSet<_>>();
20092            let mut batch = ChangeAccumulator::default();
20093            for commit in &retained_dropped {
20094                batch.conflicts = true;
20095                batch.rejections = true;
20096                batch.outcomes = true;
20097                for operation in &commit.ops {
20098                    batch.table(&operation.table);
20099                }
20100            }
20101            let mut rejections = Vec::new();
20102            for commit in &doomed {
20103                for operation in &commit.ops {
20104                    batch.table(&operation.table);
20105                }
20106                let results = commit
20107                    .ops
20108                    .iter()
20109                    .enumerate()
20110                    .map(|(op_index, operation)| {
20111                        let rejection = RejectionRecord {
20112                            client_commit_id: commit.client_commit_id.clone(),
20113                            op_index: op_index as i32,
20114                            code: "client.local_data_purged".to_owned(),
20115                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
20116                            retryable: false,
20117                            details: None,
20118                            operation: Some(CommitOperation::from(operation)),
20119                        };
20120                        rejections.push(rejection.clone());
20121                        CommitOperationOutcome::Error { rejection }
20122                    })
20123                    .collect::<Vec<_>>();
20124                self.persist_commit_outcome(
20125                    &commit.client_commit_id,
20126                    CommitOutcomeStatus::Rejected,
20127                    &results,
20128                    Some(&commit.ops),
20129                )?;
20130                self.delete_outbox_persisted(&commit.client_commit_id)?;
20131            }
20132            if !doomed.is_empty() {
20133                self.outbox
20134                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
20135                self.rejections.extend(rejections);
20136                self.prune_commit_outcomes()?;
20137                batch.status = true;
20138                batch.rejections = true;
20139                batch.outcomes = true;
20140            }
20141
20142            let mut purged_rows = 0usize;
20143            for (table, ids) in &row_ids {
20144                if ids.is_empty() {
20145                    continue;
20146                }
20147                batch.table(table);
20148                for row_id in ids {
20149                    self.delete_base_row(table, row_id)?;
20150                    purged_rows += 1;
20151                }
20152            }
20153            self.rebuild_overlay_if_dirty()?;
20154            self.delete_unreferenced_cached_blobs()?;
20155            self.conn
20156                .execute(
20157                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20158                    rusqlite::params![meta_key, canonical_plan],
20159                )
20160                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20161            Ok((
20162                batch,
20163                LocalDataPurgeResult {
20164                    already_applied: false,
20165                    purged_rows,
20166                    dropped_commits: doomed.len(),
20167                },
20168            ))
20169        })();
20170        let (batch, result) = match applied {
20171            Ok(value) => value,
20172            Err(error) => {
20173                self.rollback_observation("syncular_local_purge");
20174                self.outbox = prior_outbox;
20175                self.failed_commits = prior_failed;
20176                self.rejections = prior_rejections;
20177                self.conflicts = prior_conflicts;
20178                self.overlay_dirty.restore(prior_overlay_dirty.clone());
20179                return Err(error);
20180            }
20181        };
20182        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
20183            self.rollback_observation("syncular_local_purge");
20184            self.outbox = prior_outbox;
20185            self.failed_commits = prior_failed;
20186            self.rejections = prior_rejections;
20187            self.conflicts = prior_conflicts;
20188            self.overlay_dirty.restore(prior_overlay_dirty.clone());
20189            return Err(error);
20190        }
20191        self.cancel_sync_round();
20192        Ok(result)
20193    }
20194
20195    /// Application-authorized recovery of the replicated projection. Unlike a
20196    /// local security purge, this retains the entire outbox, device identity,
20197    /// lease, outcomes, subscription registrations, and protected bookkeeping.
20198    /// The projection reset, subscription rewind, optimistic replay, and
20199    /// idempotency marker share one savepoint for interruption safety.
20200    pub fn rebootstrap_local_data(
20201        &mut self,
20202        input: &LocalDataRebootstrapInput,
20203    ) -> Result<LocalDataRebootstrapResult, String> {
20204        if input.rebootstrap_id.is_empty()
20205            || input.rebootstrap_id.len() > 128
20206            || !is_local_operation_code_like(&input.rebootstrap_id)
20207        {
20208            return Err(
20209                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
20210                    .to_owned(),
20211            );
20212        }
20213        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
20214        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
20215            let (retained_commits, reset_subscriptions) =
20216                decode_local_rebootstrap_receipt(&persisted)?;
20217            return Ok(LocalDataRebootstrapResult {
20218                already_applied: true,
20219                retained_commits,
20220                reset_subscriptions,
20221            });
20222        }
20223        if self.stopped || self.schema_floor.is_some() {
20224            return Err(
20225                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
20226                    .to_owned(),
20227            );
20228        }
20229
20230        let retained_commits = self.outbox.len();
20231        let reset_subscriptions = self.subs.len();
20232        let prior_subs = self.subs.clone();
20233        let prior_upgrading = self.upgrading;
20234        let prior_stopped = self.stopped;
20235        let prior_schema_floor = self.schema_floor.clone();
20236        let prior_overlay_dirty = self.overlay_dirty.snapshot();
20237        let prior_sync_needed = self.sync_needed;
20238        let prior_sync_intents = self.sync_intent_queue.clone();
20239        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
20240
20241        let prior_active_round = self.active_round;
20242        self.begin_observation("syncular_local_rebootstrap")?;
20243        let mut batch = ChangeAccumulator::default();
20244        let applied = (|| -> Result<(), String> {
20245            self.run_schema_reset_observed(&mut batch, false)?;
20246            self.conn
20247                .execute(
20248                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20249                    rusqlite::params![meta_key, receipt],
20250                )
20251                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20252            self.sync_needed = true;
20253            self.sync_intent_queue.push_back(SyncIntent::Interactive);
20254            batch.status = true;
20255            Ok(())
20256        })();
20257        if let Err(error) = applied {
20258            self.rollback_observation("syncular_local_rebootstrap");
20259            self.active_round = prior_active_round;
20260            self.subs = prior_subs;
20261            self.upgrading = prior_upgrading;
20262            self.stopped = prior_stopped;
20263            self.schema_floor = prior_schema_floor;
20264            self.overlay_dirty.restore(prior_overlay_dirty.clone());
20265            self.sync_needed = prior_sync_needed;
20266            self.sync_intent_queue = prior_sync_intents;
20267            return Err(error);
20268        }
20269        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
20270            self.rollback_observation("syncular_local_rebootstrap");
20271            self.active_round = prior_active_round;
20272            self.subs = prior_subs;
20273            self.upgrading = prior_upgrading;
20274            self.stopped = prior_stopped;
20275            self.schema_floor = prior_schema_floor;
20276            self.overlay_dirty.restore(prior_overlay_dirty.clone());
20277            self.sync_needed = prior_sync_needed;
20278            self.sync_intent_queue = prior_sync_intents;
20279            return Err(error);
20280        }
20281
20282        Ok(LocalDataRebootstrapResult {
20283            already_applied: false,
20284            retained_commits,
20285            reset_subscriptions,
20286        })
20287    }
20288
20289    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
20290
20291    /// Delete base rows matching the effective scopes; `Err(())` = no local
20292    /// scope-column mapping for a key (the fail-closed case).
20293    fn purge_scope_rows(
20294        &mut self,
20295        table_name: &str,
20296        effective: &[(String, Vec<String>)],
20297    ) -> Result<(), ()> {
20298        if effective.is_empty() {
20299            return Ok(());
20300        }
20301        let table = self.schema.table(table_name).ok_or(())?.clone();
20302        let mut clauses = Vec::new();
20303        let mut params: Vec<SqlValue> = Vec::new();
20304        for (variable, values) in effective {
20305            let column = table.scope_column(variable).ok_or(())?;
20306            let placeholders: Vec<String> = values
20307                .iter()
20308                .map(|v| {
20309                    params.push(SqlValue::Text(v.clone()));
20310                    "?".to_owned()
20311                })
20312                .collect();
20313            clauses.push(format!(
20314                "{} IN ({})",
20315                quote_ident(column),
20316                placeholders.join(", ")
20317            ));
20318        }
20319        let sql = format!(
20320            "DELETE FROM {} WHERE {}",
20321            base_table(table_name),
20322            clauses.join(" AND ")
20323        );
20324        self.overlay_dirty.table(table_name);
20325        self.conn
20326            .execute(&sql, rusqlite::params_from_iter(&params))
20327            .map_err(|_| ())?;
20328        self.conn
20329            .execute(
20330                &format!(
20331                    "DELETE FROM {} WHERE {}",
20332                    visible_table(table_name),
20333                    clauses.join(" AND ")
20334                ),
20335                rusqlite::params_from_iter(&params),
20336            )
20337            .map_err(|_| ())?;
20338        Ok(())
20339    }
20340
20341    /// §3.3: drop pending commits whose upserts provably land in the
20342    /// revoked effective scopes — whole-commit, never per-operation.
20343    fn drop_doomed_outbox(
20344        &mut self,
20345        table_name: &str,
20346        effective: &[(String, Vec<String>)],
20347    ) -> Result<bool, String> {
20348        if effective.is_empty() {
20349            return Ok(false);
20350        }
20351        let Some(table) = self.schema.table(table_name).cloned() else {
20352            return Ok(false);
20353        };
20354        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
20355        for (variable, values) in effective {
20356            match table.scope_column(variable) {
20357                Some(column) => mappings.push((column, values)),
20358                None => return Ok(false), // not provable without a mapping
20359            }
20360        }
20361        let doomed: Vec<OutboxCommit> = self
20362            .outbox
20363            .iter()
20364            .filter(|commit| {
20365                commit.ops.iter().any(|op| {
20366                    op.upsert
20367                        && op.table == table_name
20368                        && op.values.as_ref().is_some_and(|values| {
20369                            mappings
20370                                .iter()
20371                                .all(|(column, allowed)| match values.get(*column) {
20372                                    Some(Value::String(s)) => allowed.contains(s),
20373                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
20374                                    Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
20375                                    _ => false,
20376                                })
20377                        })
20378                })
20379            })
20380            .cloned()
20381            .collect();
20382        if doomed.is_empty() {
20383            return Ok(false);
20384        }
20385        let mut rejections = Vec::new();
20386        for commit in &doomed {
20387            let results = commit
20388                .ops
20389                .iter()
20390                .enumerate()
20391                .map(|(op_index, operation)| {
20392                    let rejection = RejectionRecord {
20393                        client_commit_id: commit.client_commit_id.clone(),
20394                        op_index: op_index as i32,
20395                        code: "sync.scope_revoked".to_owned(),
20396                        message: "the commit was dropped because its effective scope was revoked"
20397                            .to_owned(),
20398                        retryable: false,
20399                        details: None,
20400                        operation: Some(CommitOperation::from(operation)),
20401                    };
20402                    rejections.push(rejection.clone());
20403                    CommitOperationOutcome::Error { rejection }
20404                })
20405                .collect::<Vec<_>>();
20406            self.persist_commit_outcome(
20407                &commit.client_commit_id,
20408                CommitOutcomeStatus::Rejected,
20409                &results,
20410                Some(&commit.ops),
20411            )?;
20412            self.delete_outbox_persisted(&commit.client_commit_id)?;
20413        }
20414        self.prune_commit_outcomes()?;
20415        let doomed_ids = doomed
20416            .iter()
20417            .map(|commit| commit.client_commit_id.as_str())
20418            .collect::<BTreeSet<_>>();
20419        self.outbox
20420            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
20421        self.rejections.extend(rejections);
20422        Ok(true)
20423    }
20424
20425    // -- blobs (§5.9) ----------------------------------------------------------------
20426
20427    /// §5.9.7: hash bytes into the content address, cache them, queue the
20428    /// upload (flushed before the next push, B4). Returns the canonical
20429    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
20430    /// column value.
20431    pub fn upload_blob(
20432        &mut self,
20433        bytes: &[u8],
20434        media_type: Option<String>,
20435        name: Option<String>,
20436    ) -> Result<Value, String> {
20437        let blob_id = blob_id_for(bytes);
20438        let now = self.clock_now_ms();
20439        let tx = self
20440            .conn
20441            .transaction()
20442            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20443        tx.execute(
20444            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
20445             ON CONFLICT(blob_id) DO NOTHING",
20446            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
20447        )
20448        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20449        tx.execute(
20450            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
20451            rusqlite::params![blob_id, media_type, now],
20452        )
20453        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20454        tx.commit()
20455            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20456        self.enforce_blob_cache_cap();
20457        let mut obj = Map::new();
20458        obj.insert("blobId".to_owned(), Value::from(blob_id));
20459        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
20460        if let Some(mt) = media_type {
20461            obj.insert("mediaType".to_owned(), Value::from(mt));
20462        }
20463        if let Some(n) = name {
20464            obj.insert("name".to_owned(), Value::from(n));
20465        }
20466        Ok(Value::Object(obj))
20467    }
20468
20469    /// §5.9.7: resolve an owned blob body without driver encoding. A
20470    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
20471    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
20472    pub fn fetch_blob_bytes(
20473        &mut self,
20474        transport: &mut dyn Transport,
20475        blob_id_or_ref: &str,
20476    ) -> Result<FetchedBlob, (String, String)> {
20477        let simple = |m: String| ("client.failed".to_owned(), m);
20478        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
20479            blob_id_or_ref.to_owned()
20480        } else {
20481            let value: Value = serde_json::from_str(blob_id_or_ref)
20482                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
20483            value
20484                .get("blobId")
20485                .and_then(Value::as_str)
20486                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
20487                .to_owned()
20488        };
20489        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
20490            return Ok(cached);
20491        }
20492        if !self.transport_enabled {
20493            return Err(("sync.offline".into(), "transport gate is closed".into()));
20494        }
20495        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
20496        // blob.not_found) verbatim so the harness can assert on it. The
20497        // authorized endpoint serves bytes inline OR (always-issue, presign
20498        // configured) a signed url the client fetches directly — no host auth,
20499        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
20500        #[cfg(feature = "bench-internals")]
20501        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
20502        let bytes = match transport
20503            .blob_download(&blob_id)
20504            .map_err(|e| (e.code, e.message))?
20505        {
20506            BlobDownload::Bytes(bytes) => bytes,
20507            BlobDownload::Url {
20508                url,
20509                url_expires_at_ms,
20510            } => {
20511                // §5.9.5: MUST NOT start a fetch at/past expiry.
20512                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
20513                    return Err((
20514                        "sync.segment_expired".to_owned(),
20515                        format!(
20516                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
20517                        ),
20518                    ));
20519                }
20520                transport
20521                    .fetch_blob_url(&url)
20522                    .map_err(|e| (e.code, e.message))?
20523            }
20524        };
20525        #[cfg(feature = "bench-internals")]
20526        drop(download_phase);
20527        #[cfg(feature = "bench-internals")]
20528        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
20529        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
20530        if blob_id_for(&bytes) != blob_id {
20531            return Err(simple(format!(
20532                "blob content address mismatch for {blob_id}"
20533            )));
20534        }
20535        #[cfg(feature = "bench-internals")]
20536        drop(validate_phase);
20537        #[cfg(feature = "bench-internals")]
20538        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
20539        let now = self.clock_now_ms();
20540        self.conn
20541            .execute(
20542                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
20543                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
20544            )
20545            .map_err(|e| simple(e.to_string()))?;
20546        self.conn
20547            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
20548            .map_err(|e| simple(e.to_string()))?;
20549        self.enforce_blob_cache_cap();
20550        #[cfg(feature = "bench-internals")]
20551        drop(insert_phase);
20552        self.get_cached_blob_bytes(&blob_id)
20553            .map_err(simple)?
20554            .ok_or_else(|| simple("blob cache write failed".to_owned()))
20555    }
20556
20557    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
20558        #[cfg(feature = "bench-internals")]
20559        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
20560        let mut stmt = self
20561            .conn
20562            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
20563            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20564        let mut rows = stmt
20565            .query(rusqlite::params![blob_id])
20566            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20567        if let Some(row) = rows
20568            .next()
20569            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20570        {
20571            let bytes: Vec<u8> = row
20572                .get(0)
20573                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20574            let byte_length: i64 = row
20575                .get(1)
20576                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20577            let media_type: Option<String> = row
20578                .get(2)
20579                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20580            return Ok(Some(FetchedBlob {
20581                blob_id: blob_id.to_owned(),
20582                byte_length,
20583                bytes,
20584                media_type,
20585            }));
20586        }
20587        Ok(None)
20588    }
20589
20590    /// §5.9.7 B4: upload every queued blob before push.
20591    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
20592        let pending: Vec<(String, Option<String>)> = {
20593            let mut stmt = self
20594                .conn
20595                .prepare(
20596                    "SELECT blob_id, media_type FROM (
20597                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
20598                      UNION ALL
20599                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
20600                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
20601                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
20602                    ) ORDER BY created_at_ms, blob_id",
20603                )
20604                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20605            let rows = stmt
20606                .query_map([], |row| {
20607                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
20608                })
20609                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20610            rows.collect::<Result<_, _>>()
20611                .map_err(|error| match error {
20612                    rusqlite::Error::InvalidColumnType(..)
20613                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
20614                        "sync.local_corrupt",
20615                        "Pending blob upload metadata is invalid",
20616                    ),
20617                    _ => TransportError::new("client.failed", error.to_string()),
20618                })?
20619        };
20620        let mut uploads = Vec::new();
20621        for (blob_id, media_type) in pending {
20622            let bytes = (|| {
20623                let stored: Option<(SqlValue, SqlValue)> = self
20624                    .conn
20625                    .query_row(
20626                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
20627                        rusqlite::params![blob_id],
20628                        |row| Ok((row.get(0)?, row.get(1)?)),
20629                    )
20630                    .optional()
20631                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20632                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
20633                    return Err(TransportError::new(
20634                        "sync.local_corrupt",
20635                        "Pending blob upload body is missing or corrupt",
20636                    ));
20637                };
20638                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
20639                    return Err(TransportError::new(
20640                        "sync.local_corrupt",
20641                        "Pending blob upload body is missing or corrupt",
20642                    ));
20643                }
20644                Ok(bytes)
20645            })();
20646            let failed = bytes.is_err();
20647            uploads.push(crate::round::PendingUpload {
20648                id: blob_id,
20649                bytes,
20650                media_type,
20651            });
20652            if failed {
20653                break;
20654            }
20655        }
20656        Ok(uploads)
20657    }
20658
20659    fn visible_blob_ids_sql(&self) -> String {
20660        let mut selects = Vec::new();
20661        for table in &self.schema.tables {
20662            for column in table
20663                .columns
20664                .iter()
20665                .filter(|column| column.ty == ColumnType::BlobRef)
20666            {
20667                let identifier = quote_ident(&column.name);
20668                selects.push(format!(
20669                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
20670                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
20671                     AND json_type({identifier}, '$.blobId') = 'text'",
20672                    quote_ident(&table.name),
20673                ));
20674            }
20675        }
20676        if selects.is_empty() {
20677            "SELECT NULL AS blob_id WHERE 0".to_owned()
20678        } else {
20679            selects.join(" UNION ")
20680        }
20681    }
20682
20683    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
20684    /// staged uploads pin bodies directly; no derived refcount is stored.
20685    fn enforce_blob_cache_cap(&self) {
20686        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
20687            return;
20688        };
20689        let mut total: i64 = self
20690            .conn
20691            .query_row(
20692                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
20693                [],
20694                |row| row.get(0),
20695            )
20696            .unwrap_or(0);
20697        if total <= max_bytes {
20698            return;
20699        }
20700        let query = format!(
20701            "SELECT blob_id, byte_length 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             ORDER BY created_at_ms ASC, blob_id ASC",
20706            self.visible_blob_ids_sql(),
20707        );
20708        let candidates: Vec<(String, i64)> = {
20709            let Ok(mut stmt) = self.conn.prepare(&query) else {
20710                return;
20711            };
20712            let Ok(rows) = stmt.query_map([], |row| {
20713                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
20714            }) else {
20715                return;
20716            };
20717            rows.filter_map(Result::ok).collect()
20718        };
20719        for (blob_id, byte_length) in candidates {
20720            if total <= max_bytes {
20721                break;
20722            }
20723            let _ = self.conn.execute(
20724                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
20725                rusqlite::params![blob_id],
20726            );
20727            total -= byte_length;
20728        }
20729    }
20730
20731    fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
20732        #[cfg(feature = "bench-internals")]
20733        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
20734        if !self.schema_has_blobs() {
20735            return Ok(());
20736        }
20737        self.rebuild_overlay_if_dirty()?;
20738        self.conn
20739            .execute(
20740                &format!(
20741                    "DELETE FROM _syncular_blobs
20742                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20743                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20744                   AND blob_id NOT IN ({})",
20745                    self.visible_blob_ids_sql(),
20746                ),
20747                [],
20748            )
20749            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20750        Ok(())
20751    }
20752
20753    // -- local row storage ----------------------------------------------------------
20754
20755    /// The cached per-table primary-key upsert SQL for `full_table` (see
20756    /// the `insert_sql` field: built once, reused per row).
20757    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
20758        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
20759            return sql.clone();
20760        }
20761        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
20762        columns.push(quote_ident("_syncular_version"));
20763        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
20764        let primary_key = quote_ident(&table.primary_key);
20765        let updates = columns
20766            .iter()
20767            .filter(|column| **column != primary_key)
20768            .map(|column| format!("{column}=excluded.{column}"))
20769            .collect::<Vec<_>>()
20770            .join(", ");
20771        let sql = format!(
20772            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
20773            columns.join(", "),
20774            placeholders.join(", ")
20775        );
20776        self.insert_sql
20777            .borrow_mut()
20778            .insert(full_table.to_owned(), sql.clone());
20779        sql
20780    }
20781
20782    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
20783        self.overlay_dirty.table(table_name);
20784        self.write_row(&base_table(table_name), table_name, row, version)
20785    }
20786
20787    fn write_row(
20788        &self,
20789        full_table: &str,
20790        table_name: &str,
20791        row: &Row,
20792        version: i64,
20793    ) -> Result<(), String> {
20794        #[cfg(feature = "bench-internals")]
20795        let _phase = self.benchmark_phases.start(Phase::RowWrite);
20796        let table = self
20797            .schema
20798            .table(table_name)
20799            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20800        let sql = self.insert_row_sql(full_table, table);
20801        let mut stmt = self
20802            .conn
20803            .prepare_cached(&sql)
20804            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20805        let params = row
20806            .iter()
20807            .map(RowParam::Cell)
20808            .chain(std::iter::once(RowParam::Version(version)));
20809        stmt.execute(rusqlite::params_from_iter(params))
20810            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20811        Ok(())
20812    }
20813
20814    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
20815        let table = self
20816            .schema
20817            .table(table_name)
20818            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20819        self.overlay_dirty.table(table_name);
20820        let sql = format!(
20821            "DELETE FROM {} WHERE {}",
20822            base_table(table_name),
20823            row_id_predicate(table)
20824        );
20825        let mut stmt = self
20826            .conn
20827            .prepare_cached(&sql)
20828            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20829        stmt.execute(rusqlite::params![row_id])
20830            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20831        Ok(())
20832    }
20833
20834    fn has_acknowledged_rows(&self) -> bool {
20835        self.conn
20836            .query_row(
20837                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
20838                [],
20839                |row| row.get(0),
20840            )
20841            .expect("read acknowledged intent")
20842    }
20843
20844    fn acknowledged_operations(
20845        &self,
20846        rows: Option<&HashSet<(&str, &str)>>,
20847        tables: Option<&BTreeSet<&str>>,
20848    ) -> Result<Vec<OutboxOp>, String> {
20849        let keys = rows.map(|rows| {
20850            serde_json::to_string(
20851                &rows
20852                    .iter()
20853                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
20854                    .collect::<Vec<_>>(),
20855            )
20856            .expect("row keys")
20857        });
20858        let mut statement = self
20859            .conn
20860            .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")
20861            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20862        let rows = statement
20863            .query_map(
20864                rusqlite::params![
20865                    keys,
20866                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
20867                ],
20868                |row| {
20869                    #[cfg(test)]
20870                    self.acknowledged_replay_count
20871                        .set(self.acknowledged_replay_count.get() + 1);
20872                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
20873                },
20874            )
20875            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20876        rows.map(|row| {
20877            let (op, intent) =
20878                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20879            let op: CommitOperation =
20880                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
20881            let table = self
20882                .schema
20883                .table(&op.table)
20884                .ok_or_else(|| "sync.unknown_table".to_owned())?;
20885            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
20886                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
20887            } else {
20888                op.values
20889            };
20890            Ok(OutboxOp {
20891                table: op.table,
20892                row_id: op.row_id,
20893                upsert: op.op == "upsert",
20894                base_version: op.base_version,
20895                values,
20896                insert: op.insert,
20897            })
20898        })
20899        .collect()
20900    }
20901
20902    fn drop_acknowledged_rows(
20903        &self,
20904        matches: impl Fn(&str, &Map<String, Value>) -> bool,
20905    ) -> Result<Vec<OutboxCommit>, String> {
20906        let mut statement = self
20907            .conn
20908            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
20909            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20910        let rows = statement
20911            .query_map([], |row| {
20912                Ok((
20913                    row.get::<_, String>(0)?,
20914                    row.get::<_, String>(1)?,
20915                    row.get::<_, String>(2)?,
20916                    row.get::<_, String>(3)?,
20917                ))
20918            })
20919            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20920        let mut doomed = HashSet::new();
20921        for row in rows {
20922            let (id, table, intent, row_id) =
20923                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20924            let values =
20925                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
20926            let base_matches = self
20927                .schema
20928                .table(&table)
20929                .map(|schema| self.stored_row(schema, &row_id, true))
20930                .transpose()?
20931                .flatten()
20932                .is_some_and(|(base, _)| {
20933                    let schema = self.schema.table(&table).expect("acknowledged table");
20934                    let values = schema
20935                        .columns
20936                        .iter()
20937                        .zip(base.iter())
20938                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
20939                        .collect();
20940                    matches(&table, &values)
20941                });
20942            if matches(&table, &values) || base_matches {
20943                doomed.insert(id);
20944            }
20945        }
20946        let mut dropped = Vec::new();
20947        for id in doomed {
20948            let mut statement = self
20949                .conn
20950                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
20951                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20952            let operations = statement
20953                .query_map([&id], |row| row.get::<_, String>(0))
20954                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20955            let mut ops = Vec::new();
20956            for operation in operations {
20957                let json = operation
20958                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20959                let op: CommitOperation =
20960                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
20961                self.overlay_dirty.table(&op.table);
20962                ops.push(OutboxOp {
20963                    table: op.table,
20964                    row_id: op.row_id,
20965                    upsert: op.op == "upsert",
20966                    base_version: op.base_version,
20967                    values: op.values,
20968                    insert: op.insert,
20969                });
20970            }
20971            dropped.push(OutboxCommit {
20972                client_commit_id: id.clone(),
20973                ops,
20974            });
20975            self.conn
20976                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
20977                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20978        }
20979        Ok(dropped)
20980    }
20981
20982    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
20983    /// the outbox changed since the last rebuild (the no-op sync round).
20984    fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
20985        if self.overlay_dirty.get() {
20986            self.rebuild_overlay()?;
20987        }
20988        Ok(())
20989    }
20990
20991    /// §7.1: local reads see outbox state applied optimistically — rebuild
20992    /// every visible table as (base server state) + (pending outbox replay
20993    /// on top). Optimistic rows carry version `-1`.
20994    fn rebuild_overlay(&mut self) -> Result<(), String> {
20995        #[cfg(feature = "bench-internals")]
20996        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
20997        #[cfg(test)]
20998        self.overlay_rebuild_count
20999            .set(self.overlay_rebuild_count.get() + 1);
21000        let dirty = self.overlay_dirty.snapshot();
21001        let tables: Vec<_> = self
21002            .schema
21003            .tables
21004            .iter()
21005            .filter(|table| {
21006                dirty
21007                    .as_ref()
21008                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
21009            })
21010            .cloned()
21011            .collect();
21012        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
21013        self.begin_observation("syncular_overlay")?;
21014        let rebuilt = (|| {
21015            for table in &tables {
21016                for index in &table.fts_indexes {
21017                    self.drop_fts_triggers(index)?;
21018                }
21019                let visible = visible_table(&table.name);
21020                let base = base_table(&table.name);
21021                self.conn
21022                    .execute_batch(&format!(
21023                        "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
21024                    ))
21025                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21026            }
21027            let acknowledged = self.acknowledged_operations(None, Some(&names))?;
21028            self.apply_outbox_ops(
21029                acknowledged
21030                    .iter()
21031                    .filter(|op| names.contains(op.table.as_str())),
21032            )?;
21033            for (commit, initial) in &self.failed_commits {
21034                for operation in &commit.ops {
21035                    if !names.contains(operation.table.as_str()) || !operation.upsert {
21036                        continue;
21037                    }
21038                    if let Some(table) = self.schema.table(&operation.table) {
21039                        if self.visible_row(table, &operation.row_id)?.is_none()
21040                            && operation.presented_values(table).is_some_and(|values| {
21041                                table
21042                                    .columns
21043                                    .iter()
21044                                    .all(|column| values.contains_key(&column.name))
21045                            })
21046                        {
21047                            let key = serde_json::to_string(&(
21048                                operation.table.clone(),
21049                                operation.row_id.clone(),
21050                            ))
21051                            .expect("row key serialization");
21052                            if let Some(Value::Object(values)) = initial.get(&key) {
21053                                self.apply_outbox_ops(&[OutboxOp {
21054                                    values: Some(values.clone()),
21055                                    ..operation.clone()
21056                                }])?;
21057                            }
21058                        }
21059                    }
21060                }
21061                self.apply_outbox_ops(
21062                    commit
21063                        .ops
21064                        .iter()
21065                        .filter(|op| names.contains(op.table.as_str())),
21066                )?;
21067            }
21068            for commit in &self.outbox {
21069                self.apply_outbox_ops(
21070                    commit
21071                        .ops
21072                        .iter()
21073                        .filter(|op| names.contains(op.table.as_str())),
21074                )?;
21075            }
21076            for table in &tables {
21077                for index in &table.fts_indexes {
21078                    self.rebuild_fts_projection(table, index)?;
21079                    self.create_fts_triggers(table, index)?;
21080                }
21081            }
21082            self.conn
21083                .execute_batch("RELEASE syncular_overlay")
21084                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21085            Ok(())
21086        })();
21087        if rebuilt.is_err() {
21088            self.conn
21089                .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
21090                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21091        } else {
21092            self.overlay_dirty.set(false);
21093        }
21094        rebuilt
21095    }
21096
21097    /// §7.1 replay: every operation is applied over the current visible
21098    /// state. An operation that no longer applies (a server row now holds its
21099    /// unique value) stays pending and invisible until a later replay admits
21100    /// it or the server answers its push.
21101    fn apply_outbox_ops<'a>(
21102        &self,
21103        ops: impl IntoIterator<Item = &'a OutboxOp>,
21104    ) -> Result<(), String> {
21105        #[cfg(feature = "bench-internals")]
21106        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
21107        for op in ops {
21108            #[cfg(feature = "bench-internals")]
21109            if let Some(phase) = &mut phase {
21110                phase.unit();
21111            }
21112            if op.upsert {
21113                if let Some((table, values)) = self
21114                    .schema
21115                    .table(&op.table)
21116                    .filter(|table| table.indexes.iter().any(|index| index.unique))
21117                    .and_then(|table| Some((table, op.presented_values(table)?)))
21118                {
21119                    let mut intended: Map<String, Value> = self
21120                        .visible_row(table, &op.row_id)?
21121                        .map(|(row, _)| {
21122                            table
21123                                .columns
21124                                .iter()
21125                                .zip(&row)
21126                                .map(|(column, value)| {
21127                                    (column.name.clone(), column_value_to_json(value))
21128                                })
21129                                .collect()
21130                        })
21131                        .unwrap_or_default();
21132                    intended.extend(values.into_owned());
21133                    if !self.unique_conflicts(table, &intended, false)?.is_empty() {
21134                        continue;
21135                    }
21136                }
21137            }
21138            self.apply_outbox_op(op)?;
21139        }
21140        Ok(())
21141    }
21142
21143    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
21144        let Some(table) = self.schema.table(&op.table) else {
21145            return Ok(());
21146        };
21147        let visible = visible_table(&table.name);
21148        if !op.upsert {
21149            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
21150            self.conn
21151                .prepare_cached(&sql)
21152                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
21153                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21154            return Ok(());
21155        }
21156        // §7.1: an insert-intent operation that lacks a non-nullable column
21157        // cannot encode; it stays invisible until the send-time prepass drops
21158        // it as sync.outbox_incompatible (§7.4.4).
21159        let Some(values) = op.presented_values(table) else {
21160            return Ok(());
21161        };
21162        // §7.1: the overlay applies the operation's PRESENT columns
21163        // over the current local row. A partial operation over an
21164        // absent local row leaves it absent — the server answers it
21165        // with sync.row_deleted or sync.row_missing (§6.2). An
21166        // insert-intent operation presents every column.
21167        let full = table
21168            .columns
21169            .iter()
21170            .all(|column| values.contains_key(&column.name));
21171        let local = self.visible_row(table, &op.row_id)?;
21172        if local.is_none() && !full {
21173            return Ok(());
21174        }
21175        let mut row: Row = Vec::with_capacity(table.columns.len());
21176        for (index, column) in table.columns.iter().enumerate() {
21177            let incoming = match values.get(&column.name) {
21178                Some(raw) => json_to_column_value(
21179                    if raw.get("$bytes").is_some()
21180                        && table
21181                            .encrypted_columns
21182                            .iter()
21183                            .any(|encrypted| encrypted.index == index)
21184                    {
21185                        &table.wire_columns[index]
21186                    } else {
21187                        column
21188                    },
21189                    Some(raw),
21190                ),
21191                None => Ok(local
21192                    .as_ref()
21193                    .and_then(|(values, _)| values.get(index).cloned())
21194                    .flatten()),
21195            };
21196            row.push(incoming.map_err(|_| {
21197                "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
21198            })?);
21199        }
21200        let version = local.as_ref().map_or(-1, |(_, version)| *version);
21201        self.write_row(&visible, &table.name, &row, version)
21202            .map_err(|error| {
21203                if error.starts_with("UNIQUE constraint failed:") {
21204                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
21205                } else {
21206                    error
21207                }
21208            })
21209    }
21210
21211    /// Whether the row is present in the overlay. Existence reads only the
21212    /// key: locked encrypted values stay opaque, and decoding the complete row
21213    /// would reject a valid plain write.
21214    fn visible_row_exists(&self, table: &TableSchema, row_id: &str) -> Result<bool, String> {
21215        self.conn
21216            .query_row(
21217                &format!(
21218                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
21219                    visible_table(&table.name),
21220                    row_id_predicate(table)
21221                ),
21222                rusqlite::params![row_id],
21223                |row| row.get(0),
21224            )
21225            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
21226    }
21227
21228    /// The visible (optimistic) row's values plus version, or `None` when the
21229    /// row is absent from the overlay.
21230    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
21231        self.stored_row(table, row_id, false)
21232    }
21233
21234    fn stored_row(
21235        &self,
21236        table: &TableSchema,
21237        row_id: &str,
21238        base: bool,
21239    ) -> Result<Option<(Row, i64)>, String> {
21240        let sql = format!(
21241            "SELECT * FROM {} WHERE {}",
21242            if base {
21243                base_table(&table.name)
21244            } else {
21245                visible_table(&table.name)
21246            },
21247            row_id_predicate(table)
21248        );
21249        let mut stmt = self
21250            .conn
21251            .prepare_cached(&sql)
21252            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21253        let mut rows = stmt
21254            .query(rusqlite::params![row_id])
21255            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21256        let Some(row) = rows
21257            .next()
21258            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
21259        else {
21260            return Ok(None);
21261        };
21262        let mut values = Vec::with_capacity(table.columns.len());
21263        for (index, column) in table.columns.iter().enumerate() {
21264            let raw = row
21265                .get_ref(index)
21266                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21267            if let rusqlite::types::ValueRef::Blob(bytes) = raw {
21268                if table
21269                    .encrypted_columns
21270                    .iter()
21271                    .any(|encrypted| encrypted.index == index)
21272                {
21273                    values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
21274                    continue;
21275                }
21276            }
21277            let json = sql_ref_to_json(column, raw);
21278            values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
21279                "sync.local_corrupt: persisted local row value is invalid".to_owned()
21280            })?);
21281        }
21282        let version: i64 = row
21283            .get(table.columns.len())
21284            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21285        Ok(Some((values, version)))
21286    }
21287
21288    // -- realtime (§8) ---------------------------------------------------------------
21289
21290    /// §8.8: the configured realtime policy.
21291    #[must_use]
21292    pub fn realtime_policy(&self) -> RealtimePolicy {
21293        self.realtime_policy
21294    }
21295
21296    /// §8.8: the explicit realtime availability state.
21297    #[must_use]
21298    pub fn realtime_state(&self) -> RealtimeState {
21299        self.realtime_state
21300    }
21301
21302    /// §8.8: set the realtime policy. Refused while the socket is connected:
21303    /// a policy change never silently re-routes an ownership state.
21304    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
21305        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
21306            return Err(
21307                "sync.invalid_request: disconnect realtime before changing the realtime policy"
21308                    .to_owned(),
21309            );
21310        }
21311        self.realtime_policy = policy;
21312        match policy {
21313            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
21314            RealtimePolicy::Required | RealtimePolicy::Optional
21315                if self.realtime_state == RealtimeState::Disabled =>
21316            {
21317                self.set_realtime_state(RealtimeState::Disconnected, None);
21318            }
21319            RealtimePolicy::Required | RealtimePolicy::Optional => {}
21320        }
21321        Ok(())
21322    }
21323
21324    /// Record an availability transition, keeping the reason code bounded and
21325    /// code-like (§7.6) and clearing it where it cannot apply.
21326    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
21327        self.realtime_state = state;
21328        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
21329        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
21330            self.realtime_reason_code = None;
21331            self.realtime_retry_delay_ms = None;
21332        }
21333    }
21334
21335    #[must_use]
21336    fn realtime_connected(&self) -> bool {
21337        self.realtime_state == RealtimeState::Connected
21338    }
21339
21340    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
21341        if !self.transport_enabled {
21342            return Err("sync.offline: transport gate is closed".into());
21343        }
21344        if self.realtime_policy == RealtimePolicy::Off {
21345            return Err(
21346                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
21347            );
21348        }
21349        if self.realtime_connected() {
21350            return Ok(());
21351        }
21352        self.realtime_state = RealtimeState::Connecting;
21353        match transport.realtime_connect_for_client(&self.client_id) {
21354            Ok(()) => {
21355                self.set_realtime_state(RealtimeState::Connected, None);
21356                Ok(())
21357            }
21358            Err(error) => {
21359                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
21360                Err(format!("{}: {}", error.code, error.message))
21361            }
21362        }
21363    }
21364
21365    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
21366        self.cancel_sync_round();
21367        // A deliberately disconnected or policy-disabled client has nothing
21368        // to release; a `lost` socket still does (the host owns the handle).
21369        if self.realtime_state == RealtimeState::Disconnected
21370            || self.realtime_state == RealtimeState::Disabled
21371        {
21372            return;
21373        }
21374        let _ = transport.realtime_close();
21375        self.set_realtime_state(
21376            if self.realtime_policy == RealtimePolicy::Off {
21377                RealtimeState::Disabled
21378            } else {
21379                RealtimeState::Disconnected
21380            },
21381            None,
21382        );
21383        self.presence.clear(); // §8.6.1: presence is per-connection
21384    }
21385
21386    /// §8.8: the host observed the socket end without disconnecting it (a
21387    /// peer close, an I/O error, a failed send; the native transport reports
21388    /// these as `Inbound::Disconnected`). Releases the transport, moves a
21389    /// `Connected` state to `Lost` with the transport's code as the reason, and schedules
21390    /// one background retry intent so the host reconnects and catches up.
21391    pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
21392        if self.realtime_state != RealtimeState::Connected {
21393            return;
21394        }
21395        self.cancel_sync_round();
21396        let _ = transport.realtime_close();
21397        self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
21398        self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
21399        self.presence.clear();
21400    }
21401
21402    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
21403    /// document for `scope_key`. Requires a live socket; the document is
21404    /// ephemeral (lost on disconnect). Authorization is the connection's
21405    /// registration (§8.6.3) — an unheld key is rejected loudly by the
21406    /// server with `presence.forbidden`.
21407    pub fn set_presence(
21408        &mut self,
21409        transport: &mut dyn Transport,
21410        scope_key: &str,
21411        doc: Option<&Value>,
21412    ) -> Result<(), String> {
21413        if !self.transport_enabled {
21414            return Err("sync.offline: transport gate is closed".into());
21415        }
21416        if !self.realtime_connected() {
21417            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
21418        }
21419        let text = encode_presence_publish(scope_key, doc);
21420        transport
21421            .realtime_send(&text)
21422            .map_err(|e| format!("{}: {}", e.code, e.message))
21423    }
21424
21425    /// §8.6: the peers currently present on a scope key (ephemeral).
21426    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
21427        self.presence
21428            .get(scope_key)
21429            .map(|peers| peers.values().cloned().collect())
21430            .unwrap_or_default()
21431    }
21432
21433    /// §8.6 apply an inbound presence fanout to the local map.
21434    fn apply_presence(
21435        &mut self,
21436        scope_key: String,
21437        kind: Option<PresenceKind>,
21438        actor_id: Option<String>,
21439        client_id: Option<String>,
21440        doc: Option<Value>,
21441        error: Option<String>,
21442    ) {
21443        // The publisher-directed error variant is out-of-band; nothing to
21444        // record in the peer map.
21445        if error.is_some() {
21446            return;
21447        }
21448        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
21449            return;
21450        };
21451        let peer_key = format!("{actor_id} {client_id}");
21452        match kind {
21453            PresenceKind::Leave => {
21454                if let Some(peers) = self.presence.get_mut(&scope_key) {
21455                    peers.remove(&peer_key);
21456                    if peers.is_empty() {
21457                        self.presence.remove(&scope_key);
21458                    }
21459                }
21460            }
21461            _ => {
21462                let doc = match doc {
21463                    Some(Value::Object(_)) => doc.unwrap(),
21464                    _ => return,
21465                };
21466                self.presence.entry(scope_key).or_default().insert(
21467                    peer_key,
21468                    PresencePeer {
21469                        actor_id,
21470                        client_id,
21471                        doc,
21472                    },
21473                );
21474            }
21475        }
21476    }
21477
21478    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
21479    pub fn on_realtime_text(&mut self, text: &str) {
21480        if self.stopped || !self.transport_enabled || self.security_preflight {
21481            return;
21482        }
21483        match parse_control(text) {
21484            Ok(ControlMessage::Hello { requires_sync, .. }) => {
21485                if requires_sync {
21486                    // §8.1: pull before trusting the socket for continuity.
21487                    self.set_sync_needed(true, true);
21488                }
21489            }
21490            Ok(ControlMessage::Presence {
21491                scope_key,
21492                kind,
21493                actor_id,
21494                client_id,
21495                doc,
21496                error,
21497                ..
21498            }) => {
21499                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
21500            }
21501            Ok(ControlMessage::Wake { .. }) => {
21502                // §8.3: any wake-up means "run a pull soon", never data.
21503                self.set_sync_needed(true, true);
21504            }
21505            _ => {}
21506        }
21507    }
21508
21509    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
21510    /// a pull response per section; an unapplied delta is a wake-up.
21511    /// Mailbox hosts send durable acknowledgements on their I/O executor.
21512    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
21513        let mut transport = crate::round::DownloadResults::default();
21514        self.on_realtime_binary(&mut transport, bytes);
21515        transport.controls
21516    }
21517
21518    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
21519        if self.stopped || !self.transport_enabled || self.security_preflight {
21520            return;
21521        }
21522        #[cfg(feature = "bench-internals")]
21523        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
21524        let message = match decode_message(bytes) {
21525            Ok(m) if m.msg_kind == MsgKind::Response => m,
21526            _ => {
21527                self.set_sync_needed(true, true);
21528                return;
21529            }
21530        };
21531        #[cfg(feature = "bench-internals")]
21532        drop(decode_phase);
21533        let coalesced_deletes =
21534            coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
21535                if status != SubStatus::Active {
21536                    return false;
21537                }
21538                let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
21539                    return false;
21540                };
21541                sub.state == SubState::Active
21542                    && sub.bootstrap_state.is_none()
21543                    && sub.synced_once
21544                    && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
21545            });
21546        let mut frames = message.frames.into_iter();
21547        let mut applied_cursor: Option<i64> = None;
21548        let mut any_covered = false;
21549        let mut dropped = self.begin_coalesced_apply().is_err();
21550        while let Some(frame) = frames.next() {
21551            let Frame::SubStart {
21552                id,
21553                status,
21554                effective_scopes,
21555                ..
21556            } = frame
21557            else {
21558                continue;
21559            };
21560            let mut body = Vec::new();
21561            let mut next_cursor: Option<i64> = None;
21562            for inner in frames.by_ref() {
21563                match inner {
21564                    Frame::SubEnd {
21565                        next_cursor: nc, ..
21566                    } => {
21567                        next_cursor = Some(nc);
21568                        break;
21569                    }
21570                    Frame::Unknown { .. } => {}
21571                    other => body.push(other),
21572                }
21573            }
21574            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
21575                dropped = true;
21576                continue;
21577            };
21578            let sub = &self.subs[sub_index];
21579            // §8.2: only locally active, not mid-bootstrap subscriptions
21580            // apply; skipped sections are repaired by the next pull.
21581            if status != SubStatus::Active
21582                || sub.state != SubState::Active
21583                || sub.bootstrap_state.is_some()
21584                || !sub.synced_once
21585            {
21586                dropped = true;
21587                continue;
21588            }
21589            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
21590                // Idempotent redelivery of an already-covered window.
21591                any_covered = true;
21592                continue;
21593            }
21594            let previous_effective = self.subs[sub_index].effective.clone();
21595            let previous_cursor = self.subs[sub_index].cursor;
21596            self.subs[sub_index].effective = Some(effective_scopes);
21597            let mut failed = false;
21598            for inner in body {
21599                match inner {
21600                    Frame::Commit {
21601                        tables,
21602                        changes,
21603                        commit_seq,
21604                        ..
21605                    } => {
21606                        if self
21607                            .apply_commit_frame_coalesced(
21608                                &tables,
21609                                changes,
21610                                commit_seq,
21611                                &coalesced_deletes,
21612                            )
21613                            .is_err()
21614                        {
21615                            failed = true;
21616                            break;
21617                        }
21618                    }
21619                    _ => {
21620                        failed = true;
21621                        break;
21622                    }
21623                }
21624            }
21625            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
21626                self.apply_sub_end(sub_index, cursor, None)
21627                    .ok()
21628                    .map(|()| cursor)
21629            });
21630            let Some(next_cursor) = committed else {
21631                self.subs[sub_index].effective = previous_effective;
21632                self.subs[sub_index].cursor = previous_cursor;
21633                dropped = true;
21634                continue;
21635            };
21636            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
21637        }
21638        // §1.4 rule 4: the ack follows the COMMIT. A failed COMMIT restored
21639        // the cursors, so the delta counts as a wake-up.
21640        if self.finish_coalesced_apply().is_err() {
21641            applied_cursor = None;
21642            dropped = true;
21643        }
21644        if let Some(cursor) = applied_cursor {
21645            // §8.2 ack point: the highest applied SUB_END.nextCursor.
21646            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21647            let _ = transport.realtime_send(&ack);
21648        } else if !any_covered || dropped {
21649            // §8.2: a delta not applied at all is treated as a wake-up.
21650            self.set_sync_needed(true, true);
21651        }
21652    }
21653
21654    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
21655    /// cursor across active, non-bootstrapping subscriptions that have
21656    /// synced at least once. No such subscription, no ack.
21657    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
21658        if !self.realtime_connected() {
21659            return;
21660        }
21661        let floor = self
21662            .subs
21663            .iter()
21664            .filter(|s| {
21665                s.state == SubState::Active
21666                    && s.bootstrap_state.is_none()
21667                    && s.synced_once
21668                    && s.cursor >= 0
21669            })
21670            .map(|s| s.cursor)
21671            .min();
21672        if let Some(cursor) = floor {
21673            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21674            let _ = transport.realtime_send(&ack);
21675        }
21676    }
21677}
21678
21679#[cfg(test)]
21680mod previous_version_wiring_tests {
21681    use super::*;
21682    use serde_json::{json, Value};
21683
21684    fn previous_version_schema(version: i32) -> Value {
21685        json!({
21686            "version": version,
21687            "tables": [{
21688                "name": "tasks",
21689                "primaryKey": "id",
21690                "columns": [
21691                    { "name": "id", "type": "string", "nullable": false },
21692                    { "name": "note", "type": "string", "nullable": true }
21693                ],
21694                "scopes": []
21695            }]
21696        })
21697    }
21698
21699    fn previous_version_limits() -> crate::api::ClientLimits {
21700        crate::api::ClientLimits {
21701            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
21702                enabled: true,
21703                ..Default::default()
21704            }),
21705            ..Default::default()
21706        }
21707    }
21708
21709    /// The configuration the live client carries, for a direct capture call.
21710    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
21711        previous_version_limits()
21712            .previous_version_context
21713            .expect("configured")
21714    }
21715
21716    /// Capture the container the way a bump would, so a read test starts from a
21717    /// container this client's own state will then decide about.
21718    fn recapture_previous_version(client: &SyncClient) {
21719        let replica_path = client
21720            .previous_version_replica_path()
21721            .expect("file-backed replica");
21722        let outcome = crate::previous_version::capture_previous_version_from_replica(
21723            &client.conn,
21724            &replica_path,
21725            2,
21726            2,
21727            &previous_version_config(),
21728            1,
21729        )
21730        .expect("capture");
21731        assert!(matches!(
21732            outcome,
21733            crate::previous_version::CaptureOutcome::Captured(_)
21734        ));
21735    }
21736
21737    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
21738        Subscription {
21739            id: "wiring-sub".to_owned(),
21740            table: "tasks".to_owned(),
21741            requested: Vec::new(),
21742            params: None,
21743            cursor,
21744            bootstrap_state: bootstrap_state.map(str::to_owned),
21745            state,
21746            reason_code: None,
21747            effective: None,
21748            synced_once: false,
21749        }
21750    }
21751
21752    #[test]
21753    fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
21754        let path =
21755            std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
21756        let path_str = path.to_str().expect("temp path");
21757        let schema = previous_version_schema(3);
21758        let mut older = previous_version_schema(2);
21759        older["tables"][0]["columns"] =
21760            json!([{ "name": "id", "type": "string", "nullable": false }]);
21761        let mut client = SyncClient::open_path(
21762            "downgrade".into(),
21763            &previous_version_schema(1),
21764            previous_version_limits(),
21765            path_str,
21766        )
21767        .expect("fresh");
21768        client
21769            .mutate(vec![Mutation::Upsert {
21770                table: "tasks".into(),
21771                values: Map::from_iter([
21772                    ("id".into(), json!("accepted")),
21773                    ("note".into(), json!("old")),
21774                ]),
21775                base_version: None,
21776            }])
21777            .expect("v1 write");
21778        client
21779            .recreate_with_schema(&schema)
21780            .expect("upgrade captures context");
21781        let commit = client
21782            .mutate(vec![Mutation::Upsert {
21783                table: "tasks".into(),
21784                values: Map::from_iter([
21785                    ("id".into(), json!("queued")),
21786                    ("note".into(), json!("v3-only")),
21787                ]),
21788                base_version: None,
21789            }])
21790            .expect("queued v3 write");
21791        let changes = client.conn.total_changes();
21792        let outbox = client.pending_commit_ids();
21793        assert!(outbox.contains(&commit));
21794        let descriptor = client.get_meta("localSchemaDescriptor");
21795        let container = format!(
21796            "{path_str}{}",
21797            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
21798        );
21799        let context = std::fs::read(&container).expect("captured context");
21800        assert!(client
21801            .recreate_with_schema(&older)
21802            .expect_err("refuse recreate")
21803            .starts_with("client.schema_downgrade:"));
21804        assert_eq!(client.schema.version, 3);
21805        assert_eq!(client.conn.total_changes(), changes);
21806        assert_eq!(client.pending_commit_ids(), outbox);
21807        assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
21808        assert_eq!(
21809            client
21810                .conn
21811                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21812                    row.get::<_, String>(0)
21813                })
21814                .expect("queued row"),
21815            "v3-only"
21816        );
21817        assert_eq!(std::fs::read(&container).expect("context remains"), context);
21818        drop(client);
21819        let before = std::fs::read(&path).expect("replica bytes");
21820        // open_path uses DELETE journaling. The identity open must refuse
21821        // before its normal WAL configuration changes this database.
21822        for entry in 0..3 {
21823            let error = match entry {
21824                0 => SyncClient::open_path(
21825                    "downgrade".into(),
21826                    &older,
21827                    previous_version_limits(),
21828                    path_str,
21829                ),
21830                1 => SyncClient::open_path_with_identity(
21831                    None,
21832                    &older,
21833                    previous_version_limits(),
21834                    path_str,
21835                ),
21836                _ => SyncClient::with_connection(
21837                    "downgrade".into(),
21838                    &older,
21839                    previous_version_limits(),
21840                    Connection::open(&path).expect("connection"),
21841                ),
21842            }
21843            .err()
21844            .expect("refuse open");
21845            assert!(error.starts_with("client.schema_downgrade:"));
21846            let conn = Connection::open(&path).expect("inspect");
21847            assert_eq!(
21848                conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21849                    .expect("journal mode"),
21850                "delete"
21851            );
21852            drop(conn);
21853            assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
21854            assert_eq!(
21855                std::fs::read(&container).expect("context unchanged"),
21856                context
21857            );
21858        }
21859        let reopened = SyncClient::open_path(
21860            "downgrade".into(),
21861            &schema,
21862            previous_version_limits(),
21863            path_str,
21864        )
21865        .expect("compatible reopen");
21866        assert_eq!(reopened.pending_commit_ids(), outbox);
21867        assert_eq!(
21868            reopened
21869                .conn
21870                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21871                    row.get::<_, String>(0)
21872                })
21873                .expect("queued row remains"),
21874            "v3-only"
21875        );
21876        reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
21877        reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21878        drop(reopened);
21879        let legacy = SyncClient::open_path(
21880            "downgrade".into(),
21881            &schema,
21882            previous_version_limits(),
21883            path_str,
21884        )
21885        .expect("legacy no-marker reopen");
21886        assert_eq!(legacy.pending_commit_ids(), outbox);
21887        assert!(!legacy.upgrading());
21888        assert_eq!(
21889            legacy
21890                .conn
21891                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21892                    row.get::<_, String>(0)
21893                })
21894                .expect("legacy row remains"),
21895            "v3-only"
21896        );
21897        drop(legacy);
21898        std::fs::remove_file(path).expect("remove replica");
21899        let _ = std::fs::remove_file(container);
21900    }
21901
21902    #[test]
21903    fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
21904        for marker in [
21905            "''",
21906            "'bad'",
21907            "'0'",
21908            "'-1'",
21909            "'2.5'",
21910            "'03'",
21911            "'3\n'",
21912            "' 3'",
21913            "'+3'",
21914            "'3e0'",
21915            "'2147483648'",
21916            "NULL",
21917            "X'33'",
21918        ] {
21919            let conn = Connection::open_in_memory().expect("connection");
21920            conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
21921            let error = SyncClient::with_connection(
21922                "marker".into(),
21923                &previous_version_schema(3),
21924                ClientLimits::default(),
21925                conn,
21926            )
21927            .err()
21928            .expect("refuse marker");
21929            assert!(
21930                error.starts_with("sync.local_corrupt:"),
21931                "{marker}: {error}"
21932            );
21933        }
21934        for fault in [
21935            "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
21936            "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
21937        ] {
21938            let mut client = SyncClient::new(
21939                "marker".into(),
21940                &previous_version_schema(3),
21941                ClientLimits::default(),
21942            )
21943            .expect("fresh");
21944            let commit = client
21945                .mutate(vec![Mutation::Upsert {
21946                    table: "tasks".into(),
21947                    values: Map::from_iter([("id".into(), json!("queued"))]),
21948                    base_version: None,
21949                }])
21950                .expect("queued write");
21951            client.conn.execute_batch(fault).expect("marker fault");
21952            let changes = client.conn.total_changes();
21953            let error = client
21954                .recreate_with_schema(&previous_version_schema(4))
21955                .expect_err("refuse corrupt marker");
21956            assert!(error.starts_with("sync.local_corrupt:"));
21957            assert_eq!(client.conn.total_changes(), changes);
21958            assert_eq!(client.schema.version, 3);
21959            assert_eq!(client.pending_commit_ids(), vec![commit]);
21960        }
21961        for setup in [
21962            "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
21963            "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
21964        ] {
21965            let path =
21966                std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
21967            let conn = Connection::open(&path).expect("connection");
21968            conn.execute_batch(setup)
21969                .expect("unreadable metadata fixture");
21970            drop(conn);
21971            let before = std::fs::read(&path).expect("before");
21972            let error = SyncClient::open_path_with_identity(
21973                None,
21974                &previous_version_schema(3),
21975                ClientLimits::default(),
21976                path.to_str().expect("path"),
21977            )
21978            .err()
21979            .expect("refuse unreadable marker");
21980            assert!(error.starts_with("sync.local_corrupt:"));
21981            assert_eq!(std::fs::read(&path).expect("after"), before);
21982            std::fs::remove_file(path).expect("remove fixture");
21983        }
21984    }
21985
21986    std::thread_local! {
21987        /// The pending concurrent upgrade a busy handler commits, so a lock
21988        /// collision deterministically becomes the marker race under test.
21989        static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
21990            const { RefCell::new(None) };
21991    }
21992
21993    fn commit_concurrent_upgrade(attempts: i32) -> bool {
21994        CONCURRENT_UPGRADE.with(|slot| {
21995            if let Some(banner) = slot.borrow_mut().take() {
21996                banner
21997                    .execute_batch(
21998                        "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
21999                    )
22000                    .expect("commit the concurrent upgrade");
22001            }
22002        });
22003        attempts < 10
22004    }
22005
22006    fn journal_mode(conn: &Connection) -> String {
22007        conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
22008            .expect("journal mode")
22009    }
22010
22011    fn temp_replica(label: &str) -> std::path::PathBuf {
22012        std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
22013    }
22014
22015    #[test]
22016    fn file_identity_constructor_requires_the_returned_wal_mode() {
22017        for path in [":memory:", ""] {
22018            let error = SyncClient::open_path_with_identity(
22019                None,
22020                &previous_version_schema(1),
22021                ClientLimits::default(),
22022                path,
22023            )
22024            .err()
22025            .expect("temporary SQLite databases cannot enter WAL mode");
22026            assert_eq!(
22027                error,
22028                "sync.invalid_request: file-backed client requires WAL journal mode"
22029            );
22030        }
22031        let path = temp_replica("confirmed-wal");
22032        let client = SyncClient::open_path_with_identity(
22033            None,
22034            &previous_version_schema(1),
22035            ClientLimits::default(),
22036            path.to_str().unwrap(),
22037        )
22038        .unwrap();
22039        assert_eq!(journal_mode(&client.conn), "wal");
22040        drop(client);
22041        std::fs::remove_file(path).unwrap();
22042    }
22043
22044    #[test]
22045    fn non_wal_identity_open_is_accepted_when_wal_is_not_required() {
22046        for path in [":memory:", ""] {
22047            let client = SyncClient::open_path_with_identity_journal(
22048                None,
22049                &previous_version_schema(1),
22050                ClientLimits::default(),
22051                path,
22052                false,
22053            )
22054            .expect("a host without WAL can still open the identity constructor");
22055            assert_ne!(journal_mode(&client.conn), "wal");
22056            assert_eq!(
22057                client
22058                    .conn
22059                    .query_row("PRAGMA synchronous", [], |row| row.get::<_, i64>(0))
22060                    .expect("synchronous"),
22061                2,
22062                "every identity open pins FULL durability"
22063            );
22064        }
22065    }
22066
22067    #[test]
22068    fn reset_replica_deletes_the_database_and_its_sidecars_through_the_vfs() {
22069        let path = temp_replica("reset");
22070        let path_str = path.to_str().expect("path");
22071        let client = SyncClient::open_path(
22072            "reset-replica".into(),
22073            &previous_version_schema(1),
22074            ClientLimits::default(),
22075            path_str,
22076        )
22077        .expect("replica");
22078        let original_id = client.client_id().to_owned();
22079        drop(client);
22080        for suffix in ["-journal", "-wal", "-shm"] {
22081            std::fs::write(format!("{path_str}{suffix}"), b"stale sidecar").expect("sidecar");
22082        }
22083
22084        SyncClient::reset_replica(path_str).expect("reset through the VFS");
22085
22086        for suffix in ["", "-journal", "-wal", "-shm"] {
22087            assert!(
22088                !std::path::Path::new(&format!("{path_str}{suffix}")).exists(),
22089                "reset removes the replica file {suffix:?}"
22090            );
22091        }
22092        let reopened = SyncClient::open_path_with_identity(
22093            None,
22094            &previous_version_schema(1),
22095            ClientLimits::default(),
22096            path_str,
22097        )
22098        .expect("fresh replica");
22099        assert_ne!(reopened.client_id(), original_id);
22100        assert!(reopened
22101            .query("SELECT id FROM tasks", &[])
22102            .expect("empty")
22103            .is_empty());
22104        drop(reopened);
22105        assert_eq!(
22106            SyncClient::reset_replica(":memory:").expect_err("memory has nothing to reset"),
22107            "sync.invalid_request: a memory replica has nothing to reset"
22108        );
22109    }
22110
22111    #[test]
22112    fn non_positive_generated_schema_versions_are_refused_before_storage() {
22113        for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
22114            let mut schema = previous_version_schema(1);
22115            schema["version"] = version.clone();
22116            let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
22117                .err()
22118                .expect("refuse an out-of-range generated version");
22119            assert_eq!(
22120                error,
22121                "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
22122            );
22123            // The file-backed seam refuses before the replica gains a table.
22124            let path = temp_replica("bad-version");
22125            let path_str = path.to_str().expect("path");
22126            for identity in [false, true] {
22127                let error = if identity {
22128                    SyncClient::open_path_with_identity(
22129                        None,
22130                        &schema,
22131                        ClientLimits::default(),
22132                        path_str,
22133                    )
22134                } else {
22135                    SyncClient::open_path(
22136                        "bad-version".into(),
22137                        &schema,
22138                        ClientLimits::default(),
22139                        path_str,
22140                    )
22141                }
22142                .err()
22143                .expect("refuse before storage");
22144                assert!(
22145                    error.starts_with("sync.invalid_request:"),
22146                    "{version}: {error}"
22147                );
22148                assert!(
22149                    !path.exists(),
22150                    "{version}: a refused constructor creates no replica file"
22151                );
22152            }
22153        }
22154        // A missing version is the same request error, not a parse failure.
22155        let mut schema = previous_version_schema(1);
22156        schema
22157            .as_object_mut()
22158            .expect("schema object")
22159            .remove("version");
22160        let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
22161            .err()
22162            .expect("refuse a missing version");
22163        assert!(error.starts_with("sync.invalid_request:"), "{error}");
22164    }
22165
22166    #[test]
22167    fn a_duplicated_schema_marker_is_refused_without_a_reset() {
22168        let path = temp_replica("duplicate-marker");
22169        let path_str = path.to_str().expect("path");
22170        {
22171            // A hand-built/legacy metadata table without its primary key, with
22172            // the older marker first: `query_row` would pick it and reset.
22173            let conn = Connection::open(path_str).expect("fixture");
22174            conn.execute_batch(
22175                "CREATE TABLE _syncular_meta(key TEXT, value);
22176                 INSERT INTO _syncular_meta VALUES
22177                   ('localSchemaVersion', '1'),
22178                   ('localSchemaVersion', '2'),
22179                   ('clientId', 'duplicate');",
22180            )
22181            .expect("duplicate marker fixture");
22182        }
22183        let error = SyncClient::open_path_with_identity(
22184            None,
22185            &previous_version_schema(3),
22186            ClientLimits::default(),
22187            path_str,
22188        )
22189        .err()
22190        .expect("refuse a duplicated marker");
22191        assert!(error.starts_with("sync.local_corrupt:"), "{error}");
22192        let probe = Connection::open(path_str).expect("probe");
22193        assert_eq!(
22194            probe
22195                .query_row(
22196                    "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
22197                    [],
22198                    |row| row.get::<_, i64>(0),
22199                )
22200                .expect("marker rows"),
22201            2,
22202            "the duplicated rows are left for the operator, not collapsed"
22203        );
22204        assert_eq!(
22205            probe
22206                .query_row(
22207                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
22208                    [],
22209                    |row| row.get::<_, i64>(0),
22210                )
22211                .expect("synced tables"),
22212            0,
22213            "a duplicated marker never drives the reset that recreates v3 tables"
22214        );
22215        assert_eq!(
22216            journal_mode(&probe),
22217            "delete",
22218            "the refusal leaves the journal mode alone"
22219        );
22220        drop(probe);
22221        std::fs::remove_file(path).expect("remove replica");
22222    }
22223
22224    #[test]
22225    fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
22226        let path = temp_replica("refused-wal");
22227        let path_str = path.to_str().expect("path");
22228        // `open_path` leaves the default journal mode, so a refused open is
22229        // the only thing that could switch this replica to WAL.
22230        drop(
22231            SyncClient::open_path(
22232                "owner".into(),
22233                &previous_version_schema(2),
22234                ClientLimits::default(),
22235                path_str,
22236            )
22237            .expect("v2 replica"),
22238        );
22239        let before = std::fs::read(&path).expect("before");
22240        let error = SyncClient::open_path_with_identity(
22241            Some("intruder".into()),
22242            &previous_version_schema(2),
22243            ClientLimits::default(),
22244            path_str,
22245        )
22246        .err()
22247        .expect("refuse the identity mismatch");
22248        assert!(error.starts_with("client.identity_mismatch:"), "{error}");
22249        let probe = Connection::open(path_str).expect("probe");
22250        assert_eq!(
22251            journal_mode(&probe),
22252            "delete",
22253            "a refused open must not persist WAL"
22254        );
22255        assert_eq!(std::fs::read(&path).expect("after"), before);
22256        assert_eq!(
22257            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22258            Some("2")
22259        );
22260        drop(probe);
22261        std::fs::remove_file(path).expect("remove replica");
22262    }
22263
22264    #[test]
22265    fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
22266        let path = temp_replica("marker-race");
22267        let path_str = path.to_str().expect("path");
22268        drop(
22269            SyncClient::open_path_with_identity(
22270                Some("stale".into()),
22271                &previous_version_schema(1),
22272                ClientLimits::default(),
22273                path_str,
22274            )
22275            .expect("v1 replica"),
22276        );
22277        // Another process holds the write lock with an uncommitted upgrade; a
22278        // stale open reads the v1 marker first and only then meets the lock.
22279        // The first busy therefore lands after this replica became v2.
22280        let banner = Connection::open(path_str).expect("banner");
22281        banner
22282            .execute_batch(
22283                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22284            )
22285            .expect("pending upgrade");
22286        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22287        let conn = Connection::open(path_str).expect("stale connection");
22288        conn.busy_handler(Some(commit_concurrent_upgrade))
22289            .expect("busy handler");
22290        let error = SyncClient::with_connection(
22291            "stale".into(),
22292            &previous_version_schema(1),
22293            ClientLimits::default(),
22294            conn,
22295        )
22296        .err()
22297        .expect("a concurrent upgrade must refuse the stale open");
22298        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22299        let probe = Connection::open(path_str).expect("probe");
22300        assert_eq!(
22301            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22302            Some("2"),
22303            "the concurrent upgrade survives"
22304        );
22305        drop(probe);
22306        std::fs::remove_file(path).expect("remove replica");
22307    }
22308
22309    #[test]
22310    fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
22311        let path = temp_replica("recreate-race");
22312        let path_str = path.to_str().expect("path");
22313        let mut client = SyncClient::open_path_with_identity(
22314            Some("stale".into()),
22315            &previous_version_schema(1),
22316            ClientLimits::default(),
22317            path_str,
22318        )
22319        .expect("v1 replica");
22320        client
22321            .mutate(vec![Mutation::Upsert {
22322                table: "tasks".into(),
22323                values: Map::from_iter([("id".into(), json!("queued"))]),
22324                base_version: None,
22325            }])
22326            .expect("queued write");
22327        let commit = client.pending_commit_ids();
22328        let banner = Connection::open(path_str).expect("banner");
22329        banner
22330            .execute_batch(
22331                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22332            )
22333            .expect("pending upgrade");
22334        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22335        client
22336            .conn
22337            .busy_handler(Some(commit_concurrent_upgrade))
22338            .expect("busy handler");
22339        let error = client
22340            .recreate_with_schema(&previous_version_schema(1))
22341            .expect_err("a concurrent upgrade must refuse the stale recreate");
22342        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22343        assert_eq!(client.schema.version, 1, "the compiled schema is restored");
22344        assert_eq!(
22345            client.pending_commit_ids(),
22346            commit,
22347            "the outbox is preserved"
22348        );
22349        let probe = Connection::open(path_str).expect("probe");
22350        assert_eq!(
22351            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22352            Some("2")
22353        );
22354        drop(probe);
22355        drop(client);
22356        std::fs::remove_file(path).expect("remove replica");
22357    }
22358
22359    #[test]
22360    fn failed_schema_record_writes_roll_back_recreation() {
22361        for key in [
22362            LOCAL_SCHEMA_VERSION_KEY,
22363            crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
22364        ] {
22365            let mut client = SyncClient::new(
22366                "schema-record".into(),
22367                &previous_version_schema(1),
22368                ClientLimits::default(),
22369            )
22370            .expect("v1");
22371            let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
22372            let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
22373            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");
22374            client
22375                .recreate_with_schema(&previous_version_schema(2))
22376                .expect_err("both record writes must propagate failure");
22377            assert_eq!(client.schema.version, 1);
22378            assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
22379            assert_eq!(
22380                client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
22381                descriptor
22382            );
22383            client
22384                .conn
22385                .execute_batch("DROP TRIGGER refuse_schema_record")
22386                .expect("remove fault");
22387            client
22388                .recreate_with_schema(&previous_version_schema(2))
22389                .expect("retry");
22390            assert_eq!(client.schema.version, 2);
22391        }
22392    }
22393
22394    #[test]
22395    fn a_failed_outer_commit_restores_every_reset_side_effect() {
22396        let path = temp_replica("outer-commit");
22397        let path_str = path.to_str().expect("path");
22398        // A rollback-journal replica (no WAL), so a second read connection can
22399        // hold SHARED across the recreated transaction's COMMIT.
22400        let conn = Connection::open(path_str).expect("replica connection");
22401        let mut client = SyncClient::with_connection(
22402            "outer-commit".into(),
22403            &previous_version_schema(1),
22404            previous_version_limits(),
22405            conn,
22406        )
22407        .expect("v1 replica");
22408        assert_eq!(journal_mode(&client.conn), "delete");
22409        let commit = client
22410            .mutate(vec![Mutation::Upsert {
22411                table: "tasks".into(),
22412                values: Map::from_iter([
22413                    ("id".into(), json!("queued")),
22414                    ("note".into(), json!("dropped-by-v2")),
22415                ]),
22416                base_version: None,
22417            }])
22418            .expect("queued write");
22419        client.drain_change_batches();
22420        // v2 drops `note`, so the queued upsert cannot re-encode.
22421        let mut dropped = previous_version_schema(2);
22422        dropped["tables"][0]["columns"] =
22423            json!([{ "name": "id", "type": "string", "nullable": false }]);
22424        // Prior database and memory state: the refused COMMIT must restore all
22425        // of it.
22426        let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
22427        let revision = client.local_revision();
22428        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
22429        let columns: i64 = client
22430            .conn
22431            .query_row(
22432                "SELECT count(*) FROM pragma_table_info('tasks')",
22433                [],
22434                |row| row.get(0),
22435            )
22436            .expect("column count");
22437        let rebuilds = client.overlay_rebuild_count.get();
22438        let prior_subs = client.subs.len();
22439        let prior_changes = client.change_queue.len();
22440        let prior_intents = client.sync_intent_queue.len();
22441        let prior_last_change = client.last_change.clone();
22442        let prior_overlay_dirty = client.overlay_dirty.get();
22443        let prior_sync_needed = client.sync_needed;
22444        let prior_upgrading = client.upgrading;
22445        let prior_stopped = client.stopped;
22446        let prior_schema_floor = client.schema_floor.clone();
22447        let prior_active_round = client.active_round;
22448        // The reader holds SHARED for the whole attempt: the writer takes
22449        // RESERVED, runs the reset, and fails when COMMIT needs EXCLUSIVE.
22450        let reader = Connection::open(path_str).expect("reader");
22451        reader
22452            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22453            .expect("reader snapshot");
22454        client
22455            .conn
22456            .busy_timeout(std::time::Duration::ZERO)
22457            .expect("busy timeout");
22458        let error = client
22459            .recreate_with_schema(&dropped)
22460            .expect_err("a blocked COMMIT must refuse the recreation");
22461        assert!(error.starts_with("client.storage_busy:"), "{error}");
22462        assert!(
22463            client.overlay_rebuild_count.get() > rebuilds,
22464            "the reset ran to its overlay rebuild inside the transaction"
22465        );
22466        assert!(client.conn.is_autocommit(), "the transaction is released");
22467        // Database: the rolled-back reset left nothing behind.
22468        assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
22469        assert_eq!(client.local_revision(), revision);
22470        assert_eq!(
22471            client.query("SELECT * FROM tasks", &[]).expect("rows"),
22472            rows
22473        );
22474        assert_eq!(
22475            client
22476                .conn
22477                .query_row(
22478                    "SELECT count(*) FROM pragma_table_info('tasks')",
22479                    [],
22480                    |row| { row.get::<_, i64>(0) }
22481                )
22482                .expect("column count"),
22483            columns,
22484            "the previous table layout survives the rollback"
22485        );
22486        assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
22487        // Memory: every side effect of the successful reset is restored with
22488        // the rolled-back database.
22489        assert_eq!(client.schema.version, 1, "compiled schema");
22490        assert_eq!(client.subs.len(), prior_subs);
22491        assert_eq!(client.change_queue.len(), prior_changes);
22492        assert_eq!(client.sync_intent_queue.len(), prior_intents);
22493        assert_eq!(client.last_change, prior_last_change);
22494        assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
22495        assert_eq!(client.sync_needed, prior_sync_needed);
22496        assert_eq!(client.upgrading, prior_upgrading);
22497        assert_eq!(client.stopped, prior_stopped);
22498        assert_eq!(client.schema_floor, prior_schema_floor);
22499        assert_eq!(client.active_round, prior_active_round);
22500        assert!(
22501            client.rejections.is_empty(),
22502            "the dropped-column rejection is rolled back too"
22503        );
22504        // The capture committed in its separate file before the failed replica
22505        // COMMIT. It belongs to v2 and cannot be exposed by the restored v1 core.
22506        let container = crate::previous_version::previous_version_container_path(path_str);
22507        assert!(std::path::Path::new(&container).exists());
22508        // The reader no longer needs its snapshot, so discard can clean the
22509        // replica's audit records along with the stale sibling file.
22510        drop(reader);
22511        let snapshot = client
22512            .previous_version_snapshot(&PreviousVersionReadSpec {
22513                table: "tasks".into(),
22514                ..Default::default()
22515            })
22516            .expect("refuse stale capture");
22517        assert!(!snapshot.available);
22518        assert_eq!(snapshot.current_version, 1);
22519        assert!(!std::path::Path::new(&container).exists());
22520        // The client remains usable: releasing the reader lets the identical
22521        // recreation run to completion, and the reset preserves the
22522        // incompatible intent until the send-time prepass classifies it
22523        // (§7.4.3/§7.4.4: the reset never touches the outbox).
22524        client
22525            .conn
22526            .busy_timeout(std::time::Duration::from_secs(1))
22527            .expect("busy timeout");
22528        client
22529            .recreate_with_schema(&dropped)
22530            .expect("the recreation succeeds once the reader releases");
22531        assert_eq!(client.schema.version, 2);
22532        assert_eq!(
22533            client.pending_commit_ids(),
22534            vec![commit.clone()],
22535            "the reset kept the incompatible commit pending"
22536        );
22537        assert!(
22538            client.rejections.is_empty(),
22539            "no rejection exists before the send-time prepass"
22540        );
22541        // The send boundary classifies and drops it.
22542        client
22543            .prepare_sync_round(false)
22544            .expect("the send prepass runs during round preparation");
22545        assert!(client.pending_commit_ids().is_empty());
22546        assert!(
22547            client
22548                .rejections
22549                .iter()
22550                .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
22551            "the dropped-column commit surfaced as a rejection at send time"
22552        );
22553        client.previous_version_discard();
22554        drop(client);
22555        std::fs::remove_file(path).expect("remove replica");
22556    }
22557
22558    #[test]
22559    fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
22560        let v1 = previous_version_schema(1);
22561        let mut v2 = previous_version_schema(2);
22562        // v2 drops `note`, so the queued `dropped` upsert cannot re-encode.
22563        v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
22564        let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
22565        let dropped = client
22566            .mutate(vec![Mutation::Upsert {
22567                table: "tasks".into(),
22568                values: Map::from_iter([
22569                    ("id".into(), json!("dropped")),
22570                    ("note".into(), json!("gone")),
22571                ]),
22572                base_version: None,
22573            }])
22574            .unwrap();
22575        // A full-row v1 upsert always carries the v1 columns, so the surviving
22576        // commit is a value-free delete (it encodes under any schema that still
22577        // has the table).
22578        let survivor = client
22579            .mutate(vec![Mutation::Delete {
22580                table: "tasks".into(),
22581                row_id: "gone".into(),
22582                base_version: None,
22583            }])
22584            .unwrap();
22585        client
22586            .recreate_with_schema(&v2)
22587            .expect("the reset preserves the outbox");
22588        assert_eq!(
22589            client.pending_commit_ids(),
22590            vec![dropped.clone(), survivor.clone()],
22591            "the reset preserves both commits"
22592        );
22593        assert!(client.rejections.is_empty(), "the reset classifies nothing");
22594        client.drain_change_batches();
22595        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22596        assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
22597
22598        // A fault anywhere in the atomic drop leaves the replica, the outbox,
22599        // the durable outcomes, the visible projection, the revision, and the
22600        // publish surface untouched. Clearing `syncNeeded` first keeps the
22601        // round's own status flip out of the assertion, so any published change
22602        // can only be the drop's.
22603        for fault in [
22604            "outcome-insert",
22605            "outbox-delete",
22606            "overlay-rebuild",
22607            "publish-revision",
22608        ] {
22609            client.set_sync_needed(false, false);
22610            client.drain_change_batches();
22611            let revision = client.local_revision();
22612            let trigger = match fault {
22613                "outcome-insert" => {
22614                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22615                }
22616                "outbox-delete" => {
22617                    "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22618                }
22619                // The optimized replay removes only the dropped projection, so
22620                // the replay fault belongs on the DELETE; a publish fault lands
22621                // on the revision write that `finish_observation` performs after
22622                // every durable effect already succeeded.
22623                "publish-revision" => {
22624                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22625                }
22626                _ => {
22627                    "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22628                }
22629            };
22630            client.conn.execute_batch(trigger).unwrap();
22631            let outcome = client
22632                .prepare_sync_round(false)
22633                .err()
22634                .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
22635            assert!(
22636                matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
22637                "{fault}: {outcome:?}"
22638            );
22639            client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
22640            assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
22641            assert_eq!(
22642                client.pending_commit_ids(),
22643                vec![dropped.clone(), survivor.clone()],
22644                "{fault}: the outbox is restored"
22645            );
22646            assert!(
22647                client.rejections.is_empty(),
22648                "{fault}: no in-memory rejection survives"
22649            );
22650            assert!(
22651                client.commit_outcome(&dropped).unwrap().is_none(),
22652                "{fault}: the durable outcome rolled back"
22653            );
22654            assert_eq!(client.local_revision(), revision, "{fault}: revision");
22655            assert_eq!(
22656                client.query("SELECT * FROM tasks", &[]).unwrap(),
22657                rows,
22658                "{fault}: the visible projection is unchanged"
22659            );
22660            assert!(
22661                client.drain_change_batches().is_empty(),
22662                "{fault}: nothing is published"
22663            );
22664        }
22665
22666        // A nested failure leaves the caller's transaction and earlier writes
22667        // intact; only the drop's savepoint is rolled back.
22668        client
22669            .conn
22670            .execute_batch(
22671                "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
22672             CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
22673             BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
22674            )
22675            .unwrap();
22676        assert!(client.drop_incompatible_outbox().is_err());
22677        assert!(!client.conn.is_autocommit());
22678        assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
22679        assert_eq!(
22680            client.pending_commit_ids(),
22681            vec![dropped.clone(), survivor.clone()]
22682        );
22683        assert!(client.commit_outcome(&dropped).unwrap().is_none());
22684        client
22685            .conn
22686            .execute_batch("DROP TRIGGER fail_drop; COMMIT")
22687            .unwrap();
22688
22689        // The successful drop publishes exactly once and leaves the surviving
22690        // intent queued for the same round. A log epoch is required before a
22691        // round builds push frames at all, so the classification provably runs
22692        // before any operation of the dropped commit is sent.
22693        client.set_meta(LOG_EPOCH_KEY, "epoch");
22694        client.set_sync_needed(false, false);
22695        client.drain_change_batches();
22696        let revision = client.local_revision();
22697        let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
22698        assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
22699        assert_eq!(
22700            prepared.meta.pushed_ids,
22701            vec![survivor.clone()],
22702            "the incompatible commit is classified before any operation is sent"
22703        );
22704        let rejection = client
22705            .rejections
22706            .iter()
22707            .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
22708            .expect("a sync.outbox_incompatible rejection");
22709        assert_eq!(rejection.client_commit_id, dropped);
22710        assert!(
22711            !rejection.retryable,
22712            "a schema-incompatible commit is final"
22713        );
22714        assert_eq!(
22715            client
22716                .commit_outcome(&dropped)
22717                .unwrap()
22718                .expect("a durable rejected outcome")
22719                .status,
22720            CommitOutcomeStatus::Rejected
22721        );
22722        assert_eq!(client.local_revision(), revision + 1, "one revision");
22723        let batches = client.drain_change_batches();
22724        assert_eq!(batches.len(), 1, "one change batch");
22725        assert!(
22726            batches[0].rejections_changed
22727                && batches[0].outcomes_changed
22728                && batches[0].status.is_some(),
22729            "{:?}",
22730            batches[0]
22731        );
22732        assert!(
22733            batches[0].tables.iter().any(|entry| entry.table == "tasks"),
22734            "{:?}",
22735            batches[0]
22736        );
22737        let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
22738        assert!(
22739            visible.is_empty(),
22740            "the dropped projection is undone: {visible:?}"
22741        );
22742
22743        // The observation's outer RELEASE (transaction COMMIT) is guarded too:
22744        // a rollback-journal reader holding SHARED makes it fail with the typed
22745        // busy classification, and releasing the reader lets the retry succeed.
22746        let path = temp_replica("outbox-drop-commit");
22747        let path_str = path.to_str().expect("path");
22748        let conn = Connection::open(path_str).expect("replica connection");
22749        let mut client =
22750            SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
22751                .expect("v1 replica");
22752        let dropped = client
22753            .mutate(vec![Mutation::Upsert {
22754                table: "tasks".into(),
22755                values: Map::from_iter([
22756                    ("id".into(), json!("dropped")),
22757                    ("note".into(), json!("gone")),
22758                ]),
22759                base_version: None,
22760            }])
22761            .unwrap();
22762        client
22763            .recreate_with_schema(&v2)
22764            .expect("the reset preserves the outbox");
22765        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22766        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22767        assert_eq!(rows.len(), 1, "the optimistic row is visible");
22768        client.set_sync_needed(false, false);
22769        client.drain_change_batches();
22770        let revision = client.local_revision();
22771        let reader = Connection::open(path_str).expect("reader");
22772        reader
22773            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22774            .expect("reader snapshot");
22775        client
22776            .conn
22777            .busy_timeout(std::time::Duration::ZERO)
22778            .expect("busy timeout");
22779        let outcome = client
22780            .prepare_sync_round(false)
22781            .err()
22782            .expect("a blocked COMMIT must fail the round");
22783        assert!(
22784            matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
22785            "{outcome:?}"
22786        );
22787        assert!(client.conn.is_autocommit(), "the savepoint is released");
22788        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22789        assert!(client.rejections.is_empty());
22790        assert!(client.commit_outcome(&dropped).unwrap().is_none());
22791        assert_eq!(client.local_revision(), revision);
22792        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
22793        assert!(client.drain_change_batches().is_empty());
22794        // The same round succeeds once the reader releases.
22795        drop(reader);
22796        client
22797            .conn
22798            .busy_timeout(std::time::Duration::from_secs(1))
22799            .expect("busy timeout");
22800        client
22801            .prepare_sync_round(false)
22802            .expect("the drop succeeds after the reader releases");
22803        assert!(client.pending_commit_ids().is_empty());
22804        assert!(client
22805            .rejections
22806            .iter()
22807            .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
22808        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
22809        drop(client);
22810        std::fs::remove_file(path).expect("remove replica");
22811    }
22812
22813    #[test]
22814    fn previous_version_read_reasons_come_from_live_client_state() {
22815        use crate::previous_version::{
22816            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
22817        };
22818        let path = std::env::temp_dir()
22819            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
22820            .to_string_lossy()
22821            .into_owned();
22822        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
22823        {
22824            let mut setup = SyncClient::open_path(
22825                "wiring".to_owned(),
22826                &previous_version_schema(1),
22827                previous_version_limits(),
22828                &path,
22829            )
22830            .expect("v1 install");
22831            setup
22832                .mutate(vec![Mutation::Upsert {
22833                    table: "tasks".to_owned(),
22834                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22835                    base_version: None,
22836                }])
22837                .expect("seed a row");
22838        }
22839        let mut client = SyncClient::open_path(
22840            "wiring".to_owned(),
22841            &previous_version_schema(2),
22842            previous_version_limits(),
22843            &path,
22844        )
22845        .expect("v2 bump captures");
22846        assert!(std::path::Path::new(&container).exists());
22847        let spec = PreviousVersionReadSpec {
22848            table: "tasks".to_owned(),
22849            ..Default::default()
22850        };
22851
22852        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
22853        recapture_previous_version(&client);
22854        client.lease_state = Some(LeaseState {
22855            lease_id: None,
22856            expires_at_ms: None,
22857            error_code: Some("lease.expired".to_owned()),
22858        });
22859        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22860        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22861        assert!(!snapshot.available);
22862        assert!(!std::path::Path::new(&container).exists());
22863
22864        // An expired lease uses the client clock (injected, no sleeps).
22865        recapture_previous_version(&client);
22866        client.set_now_ms(1_000);
22867        client.lease_state = Some(LeaseState {
22868            lease_id: Some("lease".to_owned()),
22869            expires_at_ms: Some(1_000),
22870            error_code: None,
22871        });
22872        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22873        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22874        assert!(!std::path::Path::new(&container).exists());
22875        client.lease_state = None;
22876
22877        // A revoked subscription ⇒ scope-revoked.
22878        recapture_previous_version(&client);
22879        client.subs.push(subscription(SubState::Revoked, 0, None));
22880        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22881        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
22882        assert!(!std::path::Path::new(&container).exists());
22883        client.subs.clear();
22884
22885        // Coverage completion: every ACTIVE subscription has a cursor and no
22886        // resume token — a still-resuming subscription is not completion.
22887        recapture_previous_version(&client);
22888        client
22889            .subs
22890            .push(subscription(SubState::Active, 0, Some("resume")));
22891        assert!(!client.previous_version_coverage_complete());
22892        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22893        assert!(snapshot.available);
22894        assert_eq!(snapshot.previous_version, Some(2));
22895        assert_eq!(snapshot.rows.len(), 1);
22896
22897        recapture_previous_version(&client);
22898        client.subs[0].bootstrap_state = None;
22899        assert!(client.previous_version_coverage_complete());
22900        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22901        assert_eq!(
22902            snapshot.reason,
22903            Some(PreviousVersionReason::CoverageComplete)
22904        );
22905        assert!(!std::path::Path::new(&container).exists());
22906        client.subs.clear();
22907
22908        // With no active subscription at all, coverage is NOT complete.
22909        assert!(!client.previous_version_coverage_complete());
22910
22911        drop(client);
22912        let _ = std::fs::remove_file(&path);
22913    }
22914
22915    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
22916    /// come from live state; this proves the OTHER caller —
22917    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
22918    /// actually removes the container for lease-inactivity, coverage
22919    /// completion and scope revocation, and leaves it alone while the
22920    /// replacement bootstrap is still resuming.
22921    #[test]
22922    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
22923        let path = std::env::temp_dir()
22924            .join(format!(
22925                "syncular-prev-lifetime-{}.db",
22926                uuid::Uuid::new_v4()
22927            ))
22928            .to_string_lossy()
22929            .into_owned();
22930        let container = format!(
22931            "{path}{}",
22932            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
22933        );
22934        let exists = || std::path::Path::new(&container).exists();
22935        {
22936            let mut setup = SyncClient::open_path(
22937                "lifetime".to_owned(),
22938                &previous_version_schema(1),
22939                previous_version_limits(),
22940                &path,
22941            )
22942            .expect("v1 install");
22943            setup
22944                .mutate(vec![Mutation::Upsert {
22945                    table: "tasks".to_owned(),
22946                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22947                    base_version: None,
22948                }])
22949                .expect("seed a row");
22950        }
22951        let mut client = SyncClient::open_path(
22952            "lifetime".to_owned(),
22953            &previous_version_schema(2),
22954            previous_version_limits(),
22955            &path,
22956        )
22957        .expect("v2 bump captures");
22958        // Pin the clock so the 24h TTL never fires in this test (the TTL has
22959        // its own coverage); the capture record's createdAtMs is 1.
22960        client.set_now_ms(1_000);
22961        assert!(exists());
22962
22963        // §7.3.5 lease error ⇒ the lifetime check drops the container.
22964        client.lease_state = Some(LeaseState {
22965            lease_id: None,
22966            expires_at_ms: None,
22967            error_code: Some("lease.expired".to_owned()),
22968        });
22969        client.previous_version_lifetime_check();
22970        assert!(!exists());
22971        client.lease_state = None;
22972
22973        // §7.4.5 coverage completion (every active sub has a cursor and no
22974        // resume token) ⇒ drop.
22975        recapture_previous_version(&client);
22976        client.subs.push(subscription(SubState::Active, 0, None));
22977        client.previous_version_lifetime_check();
22978        assert!(!exists());
22979        client.subs.clear();
22980
22981        // §3.3 scope revocation ⇒ drop.
22982        recapture_previous_version(&client);
22983        client.subs.push(subscription(SubState::Revoked, 0, None));
22984        client.previous_version_lifetime_check();
22985        assert!(!exists());
22986        client.subs.clear();
22987
22988        // Negative control: a still-resuming active subscription is NOT
22989        // coverage completion, so the container survives the same call.
22990        recapture_previous_version(&client);
22991        client
22992            .subs
22993            .push(subscription(SubState::Active, 0, Some("resume")));
22994        client.previous_version_lifetime_check();
22995        assert!(exists());
22996        client.subs.clear();
22997
22998        drop(client);
22999        let _ = std::fs::remove_file(&path);
23000        let _ = std::fs::remove_file(&container);
23001        let _ = std::fs::remove_file(format!("{container}-wal"));
23002        let _ = std::fs::remove_file(format!("{container}-shm"));
23003    }
23004}
23005
23006#[cfg(test)]
23007mod scope_move_delivery_tests {
23008    use super::*;
23009    use crate::native_transport::HostTransport;
23010    use crate::values::encode_row_json;
23011    use serde_json::json;
23012
23013    fn client() -> SyncClient {
23014        SyncClient::new(
23015            "scope-move".to_owned(),
23016            &json!({
23017                "version": 1,
23018                "tables": [{
23019                    "name": "tasks",
23020                    "primaryKey": "id",
23021                    "columns": [
23022                        { "name": "id", "type": "string", "nullable": false },
23023                        { "name": "project_id", "type": "string", "nullable": false }
23024                    ],
23025                    "scopes": [{ "pattern": "project:{project_id}" }]
23026                }]
23027            }),
23028            ClientLimits::default(),
23029        )
23030        .expect("test client")
23031    }
23032
23033    fn upsert(client: &SyncClient, project: &str) -> ssp2::model::Change {
23034        ssp2::model::Change {
23035            table_index: 0,
23036            row_id: "t1".to_owned(),
23037            op: Op::Upsert,
23038            row_version: Some(1),
23039            scopes: vec![("project_id".to_owned(), project.to_owned())],
23040            row: Some(
23041                encode_row_json(
23042                    client.schema.table("tasks").unwrap(),
23043                    "t1",
23044                    &Map::from_iter([
23045                        ("id".to_owned(), Value::from("t1")),
23046                        ("project_id".to_owned(), Value::from(project)),
23047                    ]),
23048                    &client.encryption,
23049                )
23050                .unwrap(),
23051            ),
23052        }
23053    }
23054
23055    fn delete(project: &str) -> ssp2::model::Change {
23056        ssp2::model::Change {
23057            table_index: 0,
23058            row_id: "t1".to_owned(),
23059            op: Op::Delete,
23060            row_version: None,
23061            scopes: vec![("project_id".to_owned(), project.to_owned())],
23062            row: None,
23063        }
23064    }
23065
23066    #[test]
23067    fn a_scope_move_keeps_the_row_in_either_section_order() {
23068        for order in [["old", "new"], ["new", "old"]] {
23069            let mut client = client();
23070            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23071            client
23072                .subscribe(
23073                    "old".into(),
23074                    "tasks".into(),
23075                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
23076                    None,
23077                )
23078                .unwrap();
23079            client
23080                .subscribe(
23081                    "new".into(),
23082                    "tasks".into(),
23083                    vec![("project_id".to_owned(), vec!["p2".to_owned()])],
23084                    None,
23085                )
23086                .unwrap();
23087            let seed = upsert(&client, "p1");
23088            client
23089                .apply_commit_frame(&["tasks".into()], &[seed], Some(1))
23090                .unwrap();
23091            assert_eq!(
23092                client.query("SELECT project_id FROM tasks", &[]).unwrap()[0]["project_id"],
23093                json!("p1")
23094            );
23095
23096            let mut frames = vec![Frame::RespHeader {
23097                required_schema_version: None,
23098                latest_schema_version: None,
23099                log_epoch: Some("epoch-1".to_owned()),
23100                reset_required: Some(false),
23101            }];
23102            for id in order {
23103                let project = if id == "old" { "p1" } else { "p2" };
23104                frames.push(Frame::SubStart {
23105                    id: id.to_owned(),
23106                    status: SubStatus::Active,
23107                    reason_code: String::new(),
23108                    effective_scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
23109                    bootstrap: false,
23110                });
23111                frames.push(Frame::Commit {
23112                    commit_seq: 2,
23113                    created_at_ms: 0,
23114                    actor_id: "actor".to_owned(),
23115                    tables: vec!["tasks".to_owned()],
23116                    changes: vec![if id == "old" {
23117                        delete("p1")
23118                    } else {
23119                        upsert(&client, "p2")
23120                    }],
23121                });
23122                frames.push(Frame::SubEnd {
23123                    next_cursor: 2,
23124                    bootstrap_state: None,
23125                });
23126            }
23127            let (_, meta) = client.build_request(false).unwrap();
23128            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
23129            let outcome = client.process_response(
23130                &mut transport,
23131                Message {
23132                    wire_version: WIRE_VERSION,
23133                    msg_kind: MsgKind::Response,
23134                    frames,
23135                },
23136                &meta,
23137            );
23138            assert!(
23139                matches!(outcome, SyncOutcome::Ok(_)),
23140                "{order:?}: {outcome:?}"
23141            );
23142            let rows = client
23143                .query("SELECT id, project_id FROM tasks", &[])
23144                .unwrap();
23145            assert_eq!(rows.len(), 1, "{order:?}: the row was lost");
23146            assert_eq!(rows[0]["project_id"], json!("p2"), "{order:?}");
23147        }
23148    }
23149
23150    struct RefusalTransport;
23151
23152    impl Transport for RefusalTransport {
23153        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
23154            let mut error = TransportError::new(
23155                "sync.invalid_client_id",
23156                "clientId is bound to a different actor in this partition",
23157            );
23158            error.details = Some(json!({
23159                "causeKind": "status",
23160                "httpStatus": 400,
23161                "category": "invalid-request",
23162                "retryable": false,
23163                "recommendedAction": "resetClientId"
23164            }));
23165            Err(error)
23166        }
23167
23168        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
23169            self.sync(request)
23170        }
23171
23172        fn download_segment(
23173            &mut self,
23174            _request: &SegmentRequest,
23175            _on_progress: &mut dyn FnMut(u64),
23176        ) -> Result<Vec<u8>, TransportError> {
23177            Err(TransportError::new("sync.transport_failed", "offline"))
23178        }
23179
23180        fn realtime_connect(&mut self) -> Result<(), TransportError> {
23181            Ok(())
23182        }
23183
23184        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
23185            Ok(())
23186        }
23187
23188        fn realtime_close(&mut self) -> Result<(), TransportError> {
23189            Ok(())
23190        }
23191    }
23192
23193    #[test]
23194    fn an_http_refusal_carries_its_code_and_recommended_action() {
23195        let mut client = client();
23196        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23197        let outcome = client.sync(&mut RefusalTransport);
23198        let SyncOutcome::Failed {
23199            error_code,
23200            recommended_action,
23201            details,
23202            ..
23203        } = outcome
23204        else {
23205            panic!("expected a failed round");
23206        };
23207        assert_eq!(error_code, "sync.invalid_client_id");
23208        assert_eq!(recommended_action.as_deref(), Some("resetClientId"));
23209        assert_eq!(
23210            details
23211                .as_ref()
23212                .and_then(|details| details.get("httpStatus")),
23213            Some(&json!(400))
23214        );
23215    }
23216
23217    #[test]
23218    fn reset_client_id_persists_and_changes_the_request_identity() {
23219        let mut client = client();
23220        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23221        let previous = client.client_id().to_owned();
23222        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
23223        let next = client.reset_client_id(&mut transport);
23224        assert_ne!(next, previous);
23225        assert_eq!(client.client_id(), next);
23226        assert_eq!(client.get_meta(CLIENT_ID_KEY), Some(next.clone()));
23227
23228        client
23229            .subscribe(
23230                "tasks".into(),
23231                "tasks".into(),
23232                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
23233                None,
23234            )
23235            .unwrap();
23236        let (message, _) = client.build_request(false).unwrap();
23237        assert!(matches!(
23238            message.frames.first(),
23239            Some(Frame::ReqHeader { client_id, .. }) if client_id == &next
23240        ));
23241    }
23242}
23243
23244#[cfg(test)]
23245mod insert_intent_tests {
23246    use super::*;
23247    use serde_json::{json, Value};
23248
23249    /// Version 1 `tasks(id, title)`; later versions append `extra` columns.
23250    fn tasks_schema(version: i32, extra: Value) -> Value {
23251        let mut columns = vec![
23252            json!({ "name": "id", "type": "string", "nullable": false }),
23253            json!({ "name": "title", "type": "string", "nullable": true }),
23254        ];
23255        columns.extend(extra.as_array().cloned().unwrap_or_default());
23256        json!({
23257            "version": version,
23258            "tables": [{ "name": "tasks", "primaryKey": "id", "columns": columns, "scopes": [] }]
23259        })
23260    }
23261
23262    fn with_note() -> Value {
23263        tasks_schema(
23264            2,
23265            json!([{ "name": "note", "type": "string", "nullable": true }]),
23266        )
23267    }
23268
23269    fn upsert(id: &str, title: &str) -> Mutation {
23270        Mutation::Upsert {
23271            table: "tasks".into(),
23272            values: Map::from_iter([("id".into(), json!(id)), ("title".into(), json!(title))]),
23273            base_version: None,
23274        }
23275    }
23276
23277    fn persisted_markers(client: &SyncClient) -> Vec<Vec<Option<Value>>> {
23278        let mut statement = client
23279            .conn
23280            .prepare("SELECT ops_json FROM _syncular_outbox ORDER BY seq")
23281            .unwrap();
23282        statement
23283            .query_map([], |row| row.get::<_, String>(0))
23284            .unwrap()
23285            .map(|raw| {
23286                serde_json::from_str::<Vec<Value>>(&raw.unwrap())
23287                    .unwrap()
23288                    .iter()
23289                    .map(|entry| entry.get("insert").cloned())
23290                    .collect()
23291            })
23292            .collect()
23293    }
23294
23295    /// One insert of `q` queued under version 1, then the bump to `next`.
23296    /// `strip_marker` rewrites the persisted operation into the pre-marker
23297    /// form an earlier release wrote.
23298    fn queued_insert_after_bump(next: &Value, strip_marker: bool) -> SyncClient {
23299        let mut client = SyncClient::new(
23300            "insert-intent".into(),
23301            &tasks_schema(1, json!([])),
23302            ClientLimits::default(),
23303        )
23304        .unwrap();
23305        client.mutate(vec![upsert("q", "new")]).unwrap();
23306        if strip_marker {
23307            client
23308                .conn
23309                .execute_batch(
23310                    "UPDATE _syncular_outbox SET ops_json = json_remove(ops_json, '$[0].insert')",
23311                )
23312                .unwrap();
23313            client.outbox[0].ops[0].insert = false;
23314        }
23315        client.recreate_with_schema(next).unwrap();
23316        client
23317    }
23318
23319    /// Write `q` into the base table under the current schema and replay the
23320    /// outbox over it.
23321    fn seed_base_row(client: &mut SyncClient, row: Row) {
23322        client
23323            .write_row(&base_table("tasks"), "tasks", &row, 1)
23324            .unwrap();
23325        client.overlay_dirty.set(true);
23326        client.rebuild_overlay().unwrap();
23327    }
23328
23329    fn sparse_payload(client: &SyncClient, values: Value) -> Vec<u8> {
23330        let table = client.schema.table("tasks").unwrap();
23331        encode_sparse_row_json(
23332            table,
23333            "q",
23334            values.as_object().unwrap(),
23335            &client.encryption,
23336            None,
23337        )
23338        .unwrap()
23339    }
23340
23341    #[test]
23342    fn mutate_records_insert_intent_only_for_rows_absent_at_call_time() {
23343        let mut client = SyncClient::new(
23344            "insert-intent".into(),
23345            &tasks_schema(1, json!([])),
23346            ClientLimits::default(),
23347        )
23348        .unwrap();
23349        // Both upserts of one batch see the replica as it was at call time.
23350        client
23351            .mutate(vec![upsert("a", "first"), upsert("a", "second")])
23352            .unwrap();
23353        client.mutate(vec![upsert("a", "visible")]).unwrap();
23354        client
23355            .patch(
23356                "tasks",
23357                "a",
23358                Map::from_iter([("title".into(), json!("patched"))]),
23359                None,
23360            )
23361            .unwrap();
23362        client
23363            .mutate(vec![Mutation::Delete {
23364                table: "tasks".into(),
23365                row_id: "a".into(),
23366                base_version: None,
23367            }])
23368            .unwrap();
23369
23370        let marker = Some(Value::Bool(true));
23371        assert_eq!(
23372            persisted_markers(&client),
23373            vec![
23374                vec![marker.clone(), marker],
23375                vec![None],
23376                vec![None],
23377                vec![None]
23378            ]
23379        );
23380        let flags = |commits: &[OutboxCommit]| {
23381            commits
23382                .iter()
23383                .map(|commit| commit.ops.iter().map(|op| op.insert).collect::<Vec<_>>())
23384                .collect::<Vec<_>>()
23385        };
23386        let expected = vec![vec![true, true], vec![false], vec![false], vec![false]];
23387        assert_eq!(flags(&client.outbox), expected);
23388        assert_eq!(
23389            flags(&client.load_outbox().unwrap()),
23390            expected,
23391            "reload keeps it"
23392        );
23393
23394        // The journal and failed-commit form carries the marker only when set.
23395        let insert = serde_json::to_value(CommitOperation::from(&client.outbox[0].ops[0])).unwrap();
23396        assert_eq!(insert["insert"], json!(true));
23397        assert!(
23398            serde_json::from_value::<CommitOperation>(insert)
23399                .unwrap()
23400                .insert
23401        );
23402        let update = serde_json::to_value(CommitOperation::from(&client.outbox[1].ops[0])).unwrap();
23403        assert!(update.get("insert").is_none());
23404    }
23405
23406    #[test]
23407    fn insert_intent_replays_and_encodes_an_appended_nullable_column_as_null() {
23408        let mut client = queued_insert_after_bump(&with_note(), false);
23409        assert_eq!(
23410            client
23411                .query("SELECT id, title, note FROM tasks", &[])
23412                .unwrap(),
23413            vec![Map::from_iter([
23414                ("id".into(), json!("q")),
23415                ("title".into(), json!("new")),
23416                ("note".into(), Value::Null),
23417            ])],
23418            "the replayed insert is visible with the appended column NULL"
23419        );
23420        assert_eq!(
23421            client.pending_payloads(),
23422            vec![sparse_payload(
23423                &client,
23424                json!({ "id": "q", "title": "new", "note": null })
23425            )],
23426            "the insert presents the appended column as NULL"
23427        );
23428
23429        // Over an existing local row the NULL overwrites the local value.
23430        seed_base_row(
23431            &mut client,
23432            vec![
23433                Some(ColumnValue::String("q".into())),
23434                Some(ColumnValue::String("base".into())),
23435                Some(ColumnValue::String("server".into())),
23436            ],
23437        );
23438        assert_eq!(
23439            client.query("SELECT title, note FROM tasks", &[]).unwrap(),
23440            vec![Map::from_iter([
23441                ("title".into(), json!("new")),
23442                ("note".into(), Value::Null),
23443            ])]
23444        );
23445    }
23446
23447    #[test]
23448    fn an_operation_without_the_marker_keeps_its_presence_set() {
23449        let mut client = queued_insert_after_bump(&with_note(), true);
23450        assert!(
23451            client
23452                .query("SELECT id FROM tasks", &[])
23453                .unwrap()
23454                .is_empty(),
23455            "a partial operation over an absent row stays invisible"
23456        );
23457        assert_eq!(
23458            client.pending_payloads(),
23459            vec![sparse_payload(
23460                &client,
23461                json!({ "id": "q", "title": "new" })
23462            )],
23463            "the appended column stays absent"
23464        );
23465
23466        seed_base_row(
23467            &mut client,
23468            vec![
23469                Some(ColumnValue::String("q".into())),
23470                Some(ColumnValue::String("base".into())),
23471                Some(ColumnValue::String("server".into())),
23472            ],
23473        );
23474        assert_eq!(
23475            client.query("SELECT title, note FROM tasks", &[]).unwrap(),
23476            vec![Map::from_iter([
23477                ("title".into(), json!("new")),
23478                ("note".into(), json!("server")),
23479            ])],
23480            "the stored appended value survives"
23481        );
23482    }
23483
23484    #[test]
23485    fn insert_intent_missing_a_non_nullable_column_is_outbox_incompatible() {
23486        let with_rank = tasks_schema(
23487            2,
23488            json!([{ "name": "rank", "type": "integer", "nullable": false }]),
23489        );
23490        let mut client = SyncClient::new(
23491            "insert-intent".into(),
23492            &tasks_schema(1, json!([])),
23493            ClientLimits::default(),
23494        )
23495        .unwrap();
23496        let insert = client.mutate(vec![upsert("q", "new")]).unwrap();
23497        // An update of the now-visible row carries no insert intent and keeps
23498        // its sparse presence set.
23499        let update = client.mutate(vec![upsert("q", "edited")]).unwrap();
23500        client.recreate_with_schema(&with_rank).unwrap();
23501        assert_eq!(
23502            client.pending_commit_ids(),
23503            vec![insert.clone(), update.clone()],
23504            "the reset classifies nothing"
23505        );
23506        assert!(client.rejections.is_empty());
23507        assert!(
23508            client
23509                .query("SELECT id FROM tasks", &[])
23510                .unwrap()
23511                .is_empty(),
23512            "the unencodable insert stays invisible"
23513        );
23514
23515        assert!(client.drop_incompatible_outbox().unwrap());
23516        assert_eq!(client.pending_commit_ids(), vec![update]);
23517        assert_eq!(client.rejections.len(), 1);
23518        let rejection = &client.rejections[0];
23519        assert_eq!(rejection.client_commit_id, insert);
23520        assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE);
23521        assert!(rejection.operation.as_ref().is_some_and(|op| op.insert));
23522        assert!(
23523            client.commit_outcome(&insert).unwrap().is_some(),
23524            "the journal accepts the marked operation"
23525        );
23526    }
23527
23528    #[test]
23529    fn the_stored_operation_validator_accepts_the_marker_only_on_upserts() {
23530        let upsert =
23531            json!({ "table": "tasks", "rowId": "q", "op": "upsert", "values": { "id": "q" } });
23532        let delete = json!({ "table": "tasks", "rowId": "q", "op": "delete" });
23533        let with = |base: &Value, insert: Value| {
23534            let mut value = base.clone();
23535            value["insert"] = insert;
23536            value
23537        };
23538        assert!(SyncClient::stored_operation_is_representable(&upsert));
23539        assert!(SyncClient::stored_operation_is_representable(&with(
23540            &upsert,
23541            json!(true)
23542        )));
23543        assert!(!SyncClient::stored_operation_is_representable(&with(
23544            &upsert,
23545            json!(false)
23546        )));
23547        assert!(!SyncClient::stored_operation_is_representable(&with(
23548            &upsert,
23549            json!("yes")
23550        )));
23551        assert!(SyncClient::stored_operation_is_representable(&delete));
23552        assert!(!SyncClient::stored_operation_is_representable(&with(
23553            &delete,
23554            json!(true)
23555        )));
23556    }
23557}