Skip to main content

syncular_client/
client.rs

1//! The Syncular v2 Rust client core (SPEC.md client-behavior contract):
2//! rusqlite local storage, §3.2/§3.3 effective-scope persistence + purge,
3//! §4 pull/cursor/bootstrap (§4.7 resume, §5.6 segment application), §6
4//! push with outbox order, §7 optimistic apply / rollback / replay-on-top,
5//! §2.3 clientCommitId idempotency, §8 realtime client rules, §10 errors.
6//!
7//! Built from `SPEC.md` and the committed `ssp2` codec alone — no
8//! reference to the v1 Rust tree or the v2 TypeScript client.
9
10use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use ssp2::encode_message;
12#[cfg(test)]
13use std::cell::Cell;
14use std::cell::RefCell;
15use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
16
17use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
18use rusqlite::{Connection, OpenFlags, OptionalExtension};
19use serde::{Deserialize, Serialize};
20use serde_json::{json, Map, Value};
21use sha2::{Digest, Sha256};
22use ssp2::decode::WIRE_VERSION;
23use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
24use ssp2::primitives::RawJson;
25use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
26use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
27
28use crate::api::{
29    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31    ClientDiagnosticsStorage, ClientError, ClientLimits, CommandEffects, CommitDelivery,
32    CommitOperation, CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery,
33    CommitOutcomeResolution, CommitOutcomeStatus, ConflictRecord, CoverageSnapshot,
34    DiagnosticLastChange, DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters,
35    DiagnosticSubscription, FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult,
36    LocalDataPurgeTarget, LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation,
37    PresencePeer, PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot,
38    QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
39    RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
40    SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
41    SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
42    WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43    MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49    capture_previous_version_from_replica, first_incompatibility,
50    reconcile_previous_version_at_boot, set_local_schema_descriptor,
51    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58    encode_sparse_row_json, full_row_values, is_canonical_bytes_envelope, json_to_column_value,
59    json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
60    sort_scope_map,
61};
62
63/// §4.2 default: rows and SQLite images, both applied in committed chunks.
64/// Bit 3 (signed URLs, §5.4) is
65/// added per transport capability at request-build time.
66const DEFAULT_ACCEPT: u8 = 0b0111;
67const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
68const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
69const ACCEPT_SQLITE: u8 = 1 << 2;
70const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
71const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
72
73/// §10.1 `recommendedAction` carried by a server HTTP error body.
74fn recommended_action_of(details: &Option<Value>) -> Option<String> {
75    details
76        .as_ref()?
77        .get("recommendedAction")?
78        .as_str()
79        .map(str::to_owned)
80}
81
82/// §2.2 scope migration: one commit carries the delete under the old scope and
83/// the upsert under the new one. A reader that holds both scopes receives the
84/// two changes in separate subscription sections, so section order would
85/// otherwise decide whether the row survives. Collect the `(commitSeq, table,
86/// rowId)` keys whose delete must be skipped because the same commit delivers
87/// the row's upsert in another applied section. A section that carries both
88/// changes itself keeps its own operation order.
89fn coalesced_delete_keys(
90    frames: &[Frame],
91    mut applies: impl FnMut(&str, SubStatus, Option<i64>) -> bool,
92) -> HashSet<(i64, String, String)> {
93    let mut sections = Vec::new();
94    let mut open: Option<(usize, String, SubStatus)> = None;
95    for (index, frame) in frames.iter().enumerate() {
96        match frame {
97            Frame::SubStart { id, status, .. } => {
98                open = Some((index, id.clone(), *status));
99            }
100            Frame::SubEnd { next_cursor, .. } => {
101                if let Some((start, id, status)) = open.take() {
102                    sections.push((start, index, id, status, Some(*next_cursor)));
103                }
104            }
105            _ => {}
106        }
107    }
108    type Key = (i64, String, String);
109    let mut ops: HashMap<Key, (HashSet<usize>, HashSet<usize>)> = HashMap::new();
110    let mut both_in_frame: HashSet<Key> = HashSet::new();
111    let mut frame_ordinal = 0usize;
112    for (start, end, id, status, next_cursor) in sections {
113        if !applies(&id, status, next_cursor) {
114            continue;
115        }
116        for frame in &frames[start + 1..end] {
117            let Frame::Commit {
118                commit_seq,
119                tables,
120                changes,
121                ..
122            } = frame
123            else {
124                continue;
125            };
126            frame_ordinal += 1;
127            let mut local: HashMap<(String, String), (bool, bool)> = HashMap::new();
128            for change in changes {
129                let Some(table) = tables.get(change.table_index as usize) else {
130                    continue;
131                };
132                let key = (*commit_seq, table.clone(), change.row_id.clone());
133                let entry = ops.entry(key).or_default();
134                let local_entry = local
135                    .entry((table.clone(), change.row_id.clone()))
136                    .or_default();
137                match change.op {
138                    Op::Upsert => {
139                        entry.0.insert(frame_ordinal);
140                        local_entry.0 = true;
141                    }
142                    Op::Delete => {
143                        entry.1.insert(frame_ordinal);
144                        local_entry.1 = true;
145                    }
146                }
147            }
148            for ((table, row_id), (has_upsert, has_delete)) in local {
149                if has_upsert && has_delete {
150                    both_in_frame.insert((*commit_seq, table, row_id));
151                }
152            }
153        }
154    }
155    ops.into_iter()
156        .filter_map(|(key, (upserts, deletes))| {
157            (!both_in_frame.contains(&key) && !upserts.is_empty() && !deletes.is_empty())
158                .then_some(key)
159        })
160        .collect()
161}
162
163/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
164pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
165const LOCAL_REVISION_KEY: &str = "localRevision";
166const CLIENT_ID_KEY: &str = "clientId";
167const LEASE_STATE_KEY: &str = "leaseState";
168const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
169const LOG_EPOCH_KEY: &str = "logEpoch";
170/// Persisted fail-closed quarantine marker: present while a security preflight
171/// is pending and `activateSecurity` has yet to run. Storing it in the replica
172/// keeps the gate with the data it protects, so a rebuilt host handle reopening
173/// the same file returns in preflight even though its in-memory state is fresh.
174const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
175const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
176const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
177/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
178/// the new schema after a bump. Never a wire code (§10.3).
179const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
180/// §5.11 client-local code: an encode at the push seam resolved no usable key
181/// id or named an unknown key. Never a wire code (§10.3).
182const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
183const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
184/// §7.4.4: static text for a persisted commit the current codec refuses.
185const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
186    "the persisted commit carries values the current codec refuses";
187/// §7.4.4: bounded reason on every intrinsic value drop.
188const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
189
190#[derive(Debug, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase", deny_unknown_fields)]
192struct PersistedLocalDataRebootstrapReceipt {
193    version: u8,
194    retained_commits: u64,
195    reset_subscriptions: u64,
196}
197
198fn invalid_local_rebootstrap_receipt() -> String {
199    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
200}
201
202fn encode_local_rebootstrap_receipt(
203    retained_commits: usize,
204    reset_subscriptions: usize,
205) -> Result<String, String> {
206    let retained_commits =
207        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
208    let reset_subscriptions =
209        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
210    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
211        return Err(invalid_local_rebootstrap_receipt());
212    }
213    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
214        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
215        retained_commits,
216        reset_subscriptions,
217    })
218    .map_err(|_| invalid_local_rebootstrap_receipt())
219}
220
221fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
222    // Pre-0.15.36 markers proved application but did not retain the receipt.
223    if value == "v1" {
224        return Ok((0, 0));
225    }
226    let receipt: PersistedLocalDataRebootstrapReceipt =
227        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
228    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
229        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
230        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
231    {
232        return Err(invalid_local_rebootstrap_receipt());
233    }
234    Ok((
235        usize::try_from(receipt.retained_commits)
236            .map_err(|_| invalid_local_rebootstrap_receipt())?,
237        usize::try_from(receipt.reset_subscriptions)
238            .map_err(|_| invalid_local_rebootstrap_receipt())?,
239    ))
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243enum SubState {
244    Active,
245    Revoked,
246    Failed,
247}
248
249#[cfg(test)]
250mod observation_tests {
251    use super::*;
252    use crate::native_transport::HostTransport;
253    use crate::values::encode_row_json;
254    use serde_json::json;
255
256    #[test]
257    fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
258        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
259            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
260        ]}]});
261        for name in [
262            "secret",
263            "missing",
264            "*",
265            "id FROM authority; DELETE FROM authority",
266            "_syncular_version",
267        ] {
268            let limits = ClientLimits {
269                authority_reads: vec![crate::api::AuthorityReadDeclaration {
270                    table: "authority".into(),
271                    columns: vec!["id".into(), "actor_id".into(), name.into()],
272                    scopes: [("actor_id".into(), vec!["a".into()])].into(),
273                }],
274                ..Default::default()
275            };
276            let error = SyncClient::new("authority".into(), &schema, limits)
277                .err()
278                .expect("forbidden policy");
279            assert!(
280                error.starts_with("client.authority_read_forbidden:"),
281                "{error}"
282            );
283        }
284    }
285
286    #[test]
287    fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
288        let path = std::env::temp_dir().join(format!(
289            "syncular-authority-snapshot-{}.db",
290            uuid::Uuid::new_v4()
291        ));
292        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
293            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
294        ]}]});
295        let limits = ClientLimits {
296            authority_reads: vec![crate::api::AuthorityReadDeclaration {
297                table: "authority".into(),
298                columns: vec!["id".into(), "actor_id".into(), "revision".into()],
299                scopes: [("actor_id".into(), vec!["a".into()])].into(),
300            }],
301            ..Default::default()
302        };
303        let mut client =
304            SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
305                .unwrap();
306        client.begin_security_preflight();
307        let update = |conn: &Connection, revision: i64| {
308            conn.execute_batch("BEGIN IMMEDIATE").unwrap();
309            conn.execute(
310                "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
311                [revision],
312            )
313            .unwrap();
314            conn.execute(
315                "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
316                [revision.to_string()],
317            )
318            .unwrap();
319            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();
320            conn.execute_batch("COMMIT").unwrap();
321        };
322        update(&client.conn, 1);
323        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
324        let writer_barrier = barrier.clone();
325        let writer_path = path.clone();
326        let writer = std::thread::spawn(move || {
327            let conn = Connection::open(writer_path).unwrap();
328            conn.busy_timeout(std::time::Duration::from_secs(5))
329                .unwrap();
330            writer_barrier.wait();
331            for revision in 2..=100 {
332                update(&conn, revision);
333            }
334        });
335        barrier.wait();
336        for _ in 0..100 {
337            let snapshot = client.authority_snapshot().unwrap();
338            let revision = snapshot["revision"]
339                .as_str()
340                .unwrap()
341                .parse::<i64>()
342                .unwrap();
343            assert_eq!(
344                snapshot["tables"][0]["rows"][0]["values"]["revision"],
345                revision
346            );
347            assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
348            assert_eq!(snapshot["complete"], true);
349        }
350        writer.join().unwrap();
351        assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
352        drop(client);
353        let _ = std::fs::remove_file(path);
354    }
355
356    fn client() -> SyncClient {
357        SyncClient::new(
358            "retry-test".to_owned(),
359            &json!({
360                "version": 1,
361                "tables": [{
362                    "name": "tasks",
363                    "primaryKey": "id",
364                    "columns": [
365                        { "name": "id", "type": "string", "nullable": false },
366                        { "name": "project_id", "type": "string", "nullable": false }
367                    ],
368                    "scopes": [{ "pattern": "project:{project_id}" }]
369                }]
370            }),
371            ClientLimits::default(),
372        )
373        .expect("test client")
374    }
375
376    #[test]
377    fn subscribe_many_registers_all_or_nothing() {
378        let request = |id: &str, project: &str| SubscriptionRequest {
379            id: id.to_owned(),
380            table: "tasks".to_owned(),
381            scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
382            params: None,
383        };
384        let mut client = client();
385        client
386            .subscribe_many(vec![request("a", "p1"), request("b", "p2")])
387            .unwrap();
388        let persisted = |client: &SyncClient| -> i64 {
389            client
390                .conn
391                .query_row("SELECT count(*) FROM _syncular_subscriptions", [], |row| {
392                    row.get(0)
393                })
394                .unwrap()
395        };
396        assert_eq!(persisted(&client), 2);
397        // `b` names another project: the whole call fails, `c` stays out.
398        let error = client
399            .subscribe_many(vec![request("c", "p3"), request("b", "p9")])
400            .unwrap_err();
401        assert!(error.starts_with("client.subscription_intent_mismatch:"));
402        assert!(client.subscription_state("c").is_none());
403        assert_eq!(persisted(&client), 2);
404        assert!(client.conn.is_autocommit());
405    }
406
407    #[cfg(feature = "e2ee")]
408    #[test]
409    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
410        let schema = json!({
411            "version": 1,
412            "tables": [{
413                "name": "secrets",
414                "primaryKey": "id",
415                "columns": [
416                    { "name": "id", "type": "string", "nullable": false },
417                    { "name": "encryption_key_id", "type": "string", "nullable": true },
418                    { "name": "note", "type": "bytes", "nullable": true,
419                      "encrypted": true, "declaredType": "string" }
420                ],
421                "scopes": []
422            }]
423        });
424        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
425            .expect("test client");
426        let mut config = crate::values::EncryptionConfig::default();
427        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
428        config
429            .key_id_columns
430            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
431        client.set_encryption(config);
432        // The row exists, but its key selector is absent. The encrypted edit
433        // authors locally and fails at the push seam.
434        client
435            .write_base_row(
436                "secrets",
437                &vec![Some(ColumnValue::String("ghost".into())), None, None],
438                1,
439            )
440            .unwrap();
441        client.rebuild_overlay().expect("rebuild overlay");
442        let commit_id = client
443            .patch(
444                "secrets",
445                "ghost",
446                Map::from_iter([("note".to_owned(), json!("x"))]),
447                None,
448            )
449            .expect("patch records without an author-time encrypt failure");
450        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
451        // ...and the push seam raises a durable local rejection, not a throw.
452        assert!(client.drop_unencodable_outbox(true, None).unwrap());
453        assert!(client.pending_commit_ids().is_empty());
454        let rejections = client.rejections();
455        assert_eq!(rejections.len(), 1);
456        assert_eq!(rejections[0].code, "client.encrypt_failed");
457        assert_eq!(rejections[0].client_commit_id, commit_id);
458        assert_eq!(
459            client
460                .commit_outcome(&commit_id)
461                .unwrap()
462                .map(|outcome| outcome.status),
463            Some(CommitOutcomeStatus::Rejected)
464        );
465    }
466
467    #[test]
468    fn patch_scope_read_preserves_sqlite_failure() {
469        let mut client = client();
470        client
471            .conn
472            .execute_batch(
473                "DROP TABLE tasks;
474            CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
475            )
476            .unwrap();
477        let error = client
478            .patch(
479                "tasks",
480                "t1",
481                Map::from_iter([("project_id".to_owned(), json!("p1"))]),
482                None,
483            )
484            .expect_err("the scope read must fail");
485        assert_ne!(error.code, "sync.invalid_request");
486        assert_eq!(error.code, "client.failed");
487        assert!(error.details.as_ref().unwrap()["legacyCause"]
488            .as_str()
489            .unwrap()
490            .contains("integer overflow"));
491        assert!(client.pending_commit_ids().is_empty());
492        assert!(client.conn.is_autocommit());
493    }
494
495    #[test]
496    fn patch_with_a_scope_column_matches_the_stored_local_row() {
497        let mut client = client();
498        client.create_synced_tables().unwrap();
499        client
500            .conn
501            .execute(
502                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
503                [],
504            )
505            .unwrap();
506
507        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
508        // the scope column leaves the presence set.
509        client
510            .patch(
511                "tasks",
512                "t1",
513                Map::from_iter([
514                    ("project_id".to_owned(), json!("p1")),
515                    ("id".to_owned(), json!("t1")),
516                ]),
517                None,
518            )
519            .expect("an equal scope column is a no-op");
520        let values = client.outbox[0].ops[0]
521            .values
522            .clone()
523            .expect("the patch records values");
524        assert_eq!(values.get("id"), Some(&json!("t1")));
525        assert!(!values.contains_key("project_id"));
526
527        // A differing value stays rejected.
528        let differing = client.patch(
529            "tasks",
530            "t1",
531            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
532            None,
533        );
534        let differing = differing.expect_err("a differing scope column is rejected");
535        assert_eq!(differing.code, "sync.invalid_request");
536        assert_eq!(differing.message, "the authoring request is invalid");
537        assert!(
538            differing
539                .details
540                .as_ref()
541                .and_then(|details| details["legacyCause"].as_str())
542                .is_some_and(|cause| cause.contains("patch cannot write scope column")),
543            "{differing:?}"
544        );
545
546        // An absent local row leaves nothing to prove equality against, so
547        // the patch fails closed.
548        let absent = client.patch(
549            "tasks",
550            "ghost",
551            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
552            None,
553        );
554        let absent = absent.expect_err("an absent local row is rejected");
555        assert_eq!(absent.code, "sync.row_missing");
556        assert!(absent.message.contains("requires a local row"));
557        assert_eq!(client.pending_commit_ids().len(), 1);
558    }
559
560    #[test]
561    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
562        let schema = json!({
563            "version": 1,
564            "tables": [
565                {
566                    "name": "tenants",
567                    "primaryKey": "tenant_id",
568                    "columns": [
569                        { "name": "tenant_id", "type": "string", "nullable": false },
570                        { "name": "body", "type": "string", "nullable": false }
571                    ],
572                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
573                },
574                {
575                    "name": "buckets",
576                    "primaryKey": "id",
577                    "columns": [
578                        { "name": "id", "type": "string", "nullable": false },
579                        { "name": "bucket", "type": "float", "nullable": false },
580                        { "name": "body", "type": "string", "nullable": false }
581                    ],
582                    "scopes": [{ "pattern": "bucket:{bucket}" }]
583                }
584            ]
585        });
586        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
587            .expect("test client");
588        client.create_synced_tables().unwrap();
589
590        // A primary key that is also a scope column still needs a local base.
591        client
592            .conn
593            .execute(
594                "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
595                [],
596            )
597            .unwrap();
598        client
599            .patch(
600                "tenants",
601                "t1",
602                Map::from_iter([
603                    ("tenant_id".to_owned(), json!("t1")),
604                    ("body".to_owned(), json!("created")),
605                ]),
606                None,
607            )
608            .expect("the primary-key scope column is proven equal by construction");
609
610        // A stored REAL scope value coerces from a supplied integer.
611        client
612            .conn
613            .execute(
614                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
615                [],
616            )
617            .unwrap();
618        client
619            .patch(
620                "buckets",
621                "b1",
622                Map::from_iter([
623                    ("bucket".to_owned(), json!(2)),
624                    ("body".to_owned(), json!("coerced")),
625                ]),
626                None,
627            )
628            .expect("the stored REAL value coerces from a supplied integer");
629        let values = client.outbox[1].ops[0]
630            .values
631            .clone()
632            .expect("the patch records values");
633        assert!(!values.contains_key("bucket"));
634
635        // A differing value stays rejected.
636        let differing = client.patch(
637            "buckets",
638            "b1",
639            Map::from_iter([("bucket".to_owned(), json!(3))]),
640            None,
641        );
642        let differing = differing.expect_err("a differing scope value is rejected");
643        assert_eq!(differing.code, "sync.invalid_request");
644        assert_eq!(differing.message, "the authoring request is invalid");
645        assert!(
646            differing
647                .details
648                .as_ref()
649                .and_then(|details| details["legacyCause"].as_str())
650                .is_some_and(|cause| cause.contains("patch cannot write scope column")),
651            "{differing:?}"
652        );
653    }
654
655    #[cfg(feature = "e2ee")]
656    #[test]
657    fn stored_key_fallback_resolves_an_integer_primary_key() {
658        let schema = json!({
659            "version": 1,
660            "tables": [{
661                "name": "patients",
662                "primaryKey": "id",
663                "columns": [
664                    { "name": "id", "type": "integer", "nullable": false },
665                    { "name": "encryption_key_id", "type": "string", "nullable": true },
666                    { "name": "note", "type": "bytes", "nullable": true,
667                      "encrypted": true, "declaredType": "string" }
668                ],
669                "scopes": []
670            }]
671        });
672        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
673            .expect("test client");
674        client.create_synced_tables().unwrap();
675        client
676            .conn
677            .execute(
678                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
679                 VALUES (5, 'k1', NULL, 1)",
680                [],
681            )
682            .unwrap();
683        let mut config = crate::values::EncryptionConfig::default();
684        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
685        config
686            .key_id_columns
687            .insert("patients".to_owned(), "encryption_key_id".to_owned());
688        client.set_encryption(config);
689        let table = client.schema.table("patients").unwrap();
690        let fallback = client
691            .stored_key_fallback(table, "5")
692            .expect("the stored integer key resolves the fallback");
693        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
694    }
695
696    #[test]
697    fn patch_types_an_integer_primary_key_from_its_canonical_row_id() {
698        let schema = json!({
699            "version": 1,
700            "tables": [{
701                "name": "counters",
702                "primaryKey": "id",
703                "columns": [
704                    { "name": "id", "type": "integer", "nullable": false },
705                    { "name": "title", "type": "string", "nullable": false }
706                ],
707                "scopes": []
708            }]
709        });
710        let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
711            .expect("test client");
712        client
713            .write_base_row(
714                "counters",
715                &vec![
716                    Some(ColumnValue::Integer(42)),
717                    Some(ColumnValue::String("before".into())),
718                ],
719                1,
720            )
721            .unwrap();
722        client.rebuild_overlay().expect("rebuild overlay");
723        client
724            .patch(
725                "counters",
726                "42",
727                Map::from_iter([
728                    ("id".to_owned(), json!(42)),
729                    ("title".to_owned(), json!("after")),
730                ]),
731                None,
732            )
733            .expect("an integer key that renders as the rowId is not a change");
734        let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
735        assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
736        for row_id in ["042", "+42", "42.0"] {
737            let error = client
738                .patch(
739                    "counters",
740                    row_id,
741                    Map::from_iter([("title".to_owned(), json!("x"))]),
742                    None,
743                )
744                .expect_err("a non-canonical integer rowId names no row");
745            assert_eq!(error.code, "sync.row_missing", "{row_id}");
746        }
747    }
748
749    #[test]
750    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
751        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
752            {"name":"id","type":"string","nullable":false},
753            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
754        for state in ["absent", "cached", "pinned"] {
755            let path = std::env::temp_dir().join(format!(
756                "syncular-blob-stage-{}.sqlite",
757                uuid::Uuid::new_v4()
758            ));
759            let mut client = SyncClient::open_path(
760                "stage-test".into(),
761                &schema,
762                Default::default(),
763                path.to_str().unwrap(),
764            )
765            .unwrap();
766            let bytes = b"atomic stage";
767            if state != "absent" {
768                client
769                    .upload_blob(bytes, Some("text/plain".into()), None)
770                    .unwrap();
771                if state == "cached" {
772                    client
773                        .conn
774                        .execute("DELETE FROM _syncular_blob_uploads", [])
775                        .unwrap();
776                }
777            }
778            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
779            client
780                .conn
781                .execute_batch(
782                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
783                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
784                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
785                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
786                )
787                .unwrap();
788            let error = client
789                .upload_blob(bytes, Some("application/octet-stream".into()), None)
790                .unwrap_err();
791            assert!(error.contains("injected stage failure"), "{state}: {error}");
792            assert!(client.conn.is_autocommit());
793            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
794            drop(client);
795            let mut reopened = SyncClient::open_path(
796                "stage-test".into(),
797                &schema,
798                Default::default(),
799                path.to_str().unwrap(),
800            )
801            .unwrap();
802            let durable = reopened
803                .query("SELECT * FROM _syncular_blobs", &[])
804                .unwrap();
805            reopened
806                .conn
807                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
808                .unwrap();
809            let retried = reopened.upload_blob(bytes, None, None).unwrap();
810            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
811            assert_eq!(
812                reopened
813                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
814                    .unwrap(),
815                vec![Map::from_iter([(
816                    "blob_id".into(),
817                    Value::from(blob_id_for(bytes)),
818                )])]
819            );
820            drop(reopened);
821            std::fs::remove_file(&path).unwrap();
822            assert_eq!(durable, bodies, "{state}: reopened body state");
823            assert_eq!(immediate, bodies, "{state}: immediate body state");
824        }
825    }
826
827    #[test]
828    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
829        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
830            {"name":"id","type":"string","nullable":false},
831            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
832        for fault in [
833            "missing",
834            "body-type",
835            "length",
836            "hash",
837            "pin-metadata",
838            "read",
839            "pin-delete",
840        ] {
841            let path = std::env::temp_dir().join(format!(
842                "syncular-blob-upload-{}.sqlite",
843                uuid::Uuid::new_v4()
844            ));
845            let mut client = SyncClient::open_path(
846                "upload-test".into(),
847                &schema,
848                Default::default(),
849                path.to_str().unwrap(),
850            )
851            .unwrap();
852            let bytes = b"durable upload";
853            let ref_value = client
854                .upload_blob(bytes, Some("text/plain".into()), None)
855                .unwrap();
856            let commit = client
857                .mutate(vec![Mutation::Upsert {
858                    table: "attachments".into(),
859                    values: Map::from_iter([
860                        ("id".into(), Value::from("pending")),
861                        (
862                            "file".into(),
863                            Value::from(format!(
864                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
865                                blob_id_for(bytes),
866                                bytes.len()
867                            )),
868                        ),
869                    ]),
870                    base_version: None,
871                }])
872                .unwrap();
873            client
874                .conn
875                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
876                .unwrap();
877            client.conn.execute_batch(match fault {
878                "missing" => "DELETE FROM _syncular_blobs",
879                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
880                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
881                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
882                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
883                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
884                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
885            }).unwrap();
886            let state = if fault == "read" {
887                Vec::new()
888            } else {
889                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
890            };
891            let mut transport = CountingRealtimeTransport::default();
892            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
893            assert_eq!(
894                error.code,
895                if matches!(fault, "read" | "pin-delete") {
896                    "client.failed"
897                } else {
898                    "sync.local_corrupt"
899                },
900                "{fault}"
901            );
902            assert_eq!(
903                transport.blob_uploads.len(),
904                usize::from(fault == "pin-delete"),
905                "{fault}: corrupt bytes must never be uploaded"
906            );
907            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
908            if fault != "read" {
909                assert_eq!(
910                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
911                    state
912                );
913            } else {
914                client
915                    .conn
916                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
917                    .unwrap();
918            }
919            drop(client);
920            let mut reopened = SyncClient::open_path(
921                "upload-test".into(),
922                &schema,
923                Default::default(),
924                path.to_str().unwrap(),
925            )
926            .unwrap();
927            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
928            if fault != "read" {
929                assert_eq!(
930                    reopened
931                        .query("SELECT * FROM _syncular_blobs", &[])
932                        .unwrap(),
933                    state
934                );
935            }
936            if fault == "pin-delete" {
937                reopened
938                    .conn
939                    .execute_batch("DROP TRIGGER fail_pin_delete")
940                    .unwrap();
941            }
942            if fault == "pin-metadata" {
943                reopened
944                    .conn
945                    .execute(
946                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
947                        [],
948                    )
949                    .unwrap();
950            }
951            reopened
952                .conn
953                .execute_batch(
954                    "DELETE FROM _syncular_blobs;
955                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
956                )
957                .unwrap();
958            transport.blob_uploads.clear();
959            reopened.flush_blob_uploads(&mut transport).unwrap();
960            assert_eq!(
961                transport.blob_uploads,
962                [(blob_id_for(bytes), bytes.to_vec())]
963            );
964            assert_eq!(
965                reopened
966                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
967                    .unwrap(),
968                Vec::<Map<String, Value>>::new()
969            );
970            assert_eq!(reopened.pending_commit_ids(), [commit]);
971            assert_eq!(
972                reopened
973                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
974                    .unwrap()
975                    .unwrap()
976                    .bytes,
977                bytes
978            );
979            drop(reopened);
980            std::fs::remove_file(path).unwrap();
981        }
982    }
983
984    /// §1.4 rule 4: `sections` subscriptions, each with `commits` COMMIT
985    /// frames of `rows` rows. Returns the client with its subscriptions
986    /// registered and the response.
987    fn coalesced_fixture(sections: usize, commits: usize, rows: usize) -> (SyncClient, Message) {
988        let mut client = client();
989        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
990        let mut response = Message {
991            wire_version: WIRE_VERSION,
992            msg_kind: MsgKind::Response,
993            frames: vec![Frame::RespHeader {
994                required_schema_version: None,
995                latest_schema_version: None,
996                log_epoch: Some("epoch-1".into()),
997                reset_required: Some(false),
998            }],
999        };
1000        let mut seq = 0;
1001        for section in 0..sections {
1002            let project = format!("p{section}");
1003            let scopes = vec![("project_id".to_owned(), vec![project.clone()])];
1004            client
1005                .subscribe(format!("s{section}"), "tasks".into(), scopes.clone(), None)
1006                .unwrap();
1007            let sub = client.subs.last_mut().unwrap();
1008            sub.cursor = 0;
1009            sub.synced_once = true;
1010            sub.effective = Some(scopes.clone());
1011            let sub = sub.clone();
1012            client.persist_sub(&sub).unwrap();
1013            response.frames.push(Frame::SubStart {
1014                id: format!("s{section}"),
1015                status: SubStatus::Active,
1016                reason_code: "".into(),
1017                effective_scopes: scopes,
1018                bootstrap: false,
1019            });
1020            for _ in 0..commits {
1021                seq += 1;
1022                let changes = (0..rows)
1023                    .map(|row| {
1024                        let id = format!("{project}-{seq}-{row}");
1025                        let values = Map::from_iter([
1026                            ("id".into(), Value::from(id.clone())),
1027                            ("project_id".into(), Value::from(project.clone())),
1028                        ]);
1029                        ssp2::model::Change {
1030                            table_index: 0,
1031                            row_id: id.clone(),
1032                            op: Op::Upsert,
1033                            row_version: Some(seq),
1034                            scopes: vec![("project_id".into(), project.clone())],
1035                            row: Some(
1036                                encode_row_json(
1037                                    client.schema.table("tasks").unwrap(),
1038                                    &id,
1039                                    &values,
1040                                    &client.encryption,
1041                                )
1042                                .unwrap(),
1043                            ),
1044                        }
1045                    })
1046                    .collect();
1047                response.frames.push(Frame::Commit {
1048                    commit_seq: seq,
1049                    actor_id: "writer".into(),
1050                    created_at_ms: 1,
1051                    tables: vec!["tasks".into()],
1052                    changes,
1053                });
1054            }
1055            response.frames.push(Frame::SubEnd {
1056                next_cursor: seq,
1057                bootstrap_state: None,
1058            });
1059        }
1060        client.drain_change_batches();
1061        (client, response)
1062    }
1063
1064    fn count_commits(client: &SyncClient) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
1065        let commits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1066        let counter = commits.clone();
1067        client
1068            .conn
1069            .commit_hook(Some(move || {
1070                counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1071                false
1072            }))
1073            .expect("commit hook");
1074        commits
1075    }
1076
1077    #[test]
1078    fn a_response_of_many_small_sections_commits_once() {
1079        for realtime in [false, true] {
1080            let (mut client, response) = coalesced_fixture(70, 1, 1);
1081            client.realtime_state = RealtimeState::Connected;
1082            client.realtime_reason_code = None;
1083            let mut transport = CountingRealtimeTransport::default();
1084            let (_, meta) = client.build_request(false).unwrap();
1085            let commits = count_commits(&client);
1086            if realtime {
1087                client.on_realtime_binary(&mut transport, &encode_message(&response));
1088            } else {
1089                assert!(matches!(
1090                    client.process_response(&mut transport, response, &meta),
1091                    SyncOutcome::Ok(_)
1092                ));
1093            }
1094            assert_eq!(
1095                commits.load(std::sync::atomic::Ordering::SeqCst),
1096                1,
1097                "{realtime}"
1098            );
1099            assert!(client.conn.is_autocommit());
1100            assert!(client.subs.iter().all(|sub| sub.cursor > 0));
1101            // Every frame keeps its own revision, published in order.
1102            let revisions = client
1103                .drain_change_batches()
1104                .into_iter()
1105                .filter(|batch| !batch.tables.is_empty())
1106                .map(|batch| batch.revision)
1107                .collect::<Vec<_>>();
1108            assert_eq!(
1109                revisions,
1110                (1..=70).map(|r: u64| r.to_string()).collect::<Vec<_>>()
1111            );
1112            assert_eq!(client.query("SELECT id FROM tasks", &[]).unwrap().len(), 70);
1113            // The ack follows the COMMIT.
1114            assert_eq!(
1115                transport
1116                    .messages
1117                    .iter()
1118                    .filter(|m| m.contains("\"ack\""))
1119                    .count(),
1120                1,
1121                "{realtime}"
1122            );
1123        }
1124    }
1125
1126    #[test]
1127    fn a_response_past_the_row_bound_commits_per_bound() {
1128        // 3 frames of 600 rows: no two fit one 1,024-row transaction.
1129        let (mut client, response) = coalesced_fixture(1, 3, 600);
1130        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1131        let (_, meta) = client.build_request(false).unwrap();
1132        let commits = count_commits(&client);
1133        assert!(matches!(
1134            client.process_response(&mut transport, response, &meta),
1135            SyncOutcome::Ok(_)
1136        ));
1137        assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 3);
1138        assert_eq!(
1139            client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1140            1800
1141        );
1142        assert_eq!(client.subs[0].cursor, 3);
1143    }
1144
1145    #[test]
1146    fn a_failed_coalesced_commit_restores_memory_and_publishes_nothing() {
1147        // Two sections of two frames of 600 rows: the bound commits the first
1148        // frame alone, so the deferred violation it carries fails that
1149        // mid-response COMMIT, and every later frame of the response fails.
1150        let (mut client, response) = coalesced_fixture(2, 2, 600);
1151        client
1152            .conn
1153            .execute_batch(
1154                "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",
1155            )
1156            .unwrap();
1157        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1158        let (_, meta) = client.build_request(false).unwrap();
1159        let subs = client.subs.clone();
1160        let revision = client.local_revision();
1161        let outcome = client.process_response(&mut transport, response.clone(), &meta);
1162        assert!(
1163            matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == "storage.failed"),
1164            "{outcome:?}"
1165        );
1166        assert!(client.conn.is_autocommit());
1167        assert!(client.coalesced.is_none() && client.coalesced_failure.is_none());
1168        assert_eq!(client.local_revision(), revision);
1169        assert!(client
1170            .drain_change_batches()
1171            .iter()
1172            .all(|batch| batch.tables.is_empty()));
1173        assert!(client
1174            .query("SELECT id FROM tasks", &[])
1175            .unwrap()
1176            .is_empty());
1177        assert_eq!(
1178            client.subs.iter().map(|s| s.cursor).collect::<Vec<_>>(),
1179            subs.iter().map(|s| s.cursor).collect::<Vec<_>>()
1180        );
1181        // The next response applies in full.
1182        client
1183            .conn
1184            .execute_batch("DROP TRIGGER fail_commit")
1185            .unwrap();
1186        let (_, meta) = client.build_request(false).unwrap();
1187        assert!(matches!(
1188            client.process_response(&mut transport, response, &meta),
1189            SyncOutcome::Ok(_)
1190        ));
1191        assert_eq!(
1192            client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1193            2400
1194        );
1195    }
1196
1197    #[test]
1198    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
1199        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1200            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
1201            "scopes":[{"pattern":"project:{project_id}"}]}]});
1202        for realtime in [false, true] {
1203            for fault in [
1204                "none",
1205                "row",
1206                "revision",
1207                "commit",
1208                "cursor",
1209                "error",
1210                "missing-end",
1211            ] {
1212                if realtime && fault == "missing-end" {
1213                    continue;
1214                }
1215                let path = std::env::temp_dir()
1216                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
1217                let mut client = SyncClient::open_path(
1218                    "frame-test".into(),
1219                    &schema,
1220                    Default::default(),
1221                    path.to_str().unwrap(),
1222                )
1223                .unwrap();
1224                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1225                client
1226                    .subscribe(
1227                        "tasks".into(),
1228                        "tasks".into(),
1229                        vec![("project_id".into(), vec!["p1".into()])],
1230                        None,
1231                    )
1232                    .unwrap();
1233                client.subs[0].cursor = 0;
1234                client.subs[0].synced_once = true;
1235                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1236                client.persist_sub(&client.subs[0]).unwrap();
1237                client.realtime_state = RealtimeState::Connected;
1238                client.realtime_reason_code = None;
1239                client.drain_change_batches();
1240                let mut transport = CountingRealtimeTransport::default();
1241                let (_, meta) = client.build_request(false).unwrap();
1242                let mut response = Message {
1243                    wire_version: WIRE_VERSION,
1244                    msg_kind: MsgKind::Response,
1245                    frames: vec![
1246                        Frame::RespHeader {
1247                            required_schema_version: None,
1248                            latest_schema_version: None,
1249                            log_epoch: Some("epoch-1".into()),
1250                            reset_required: Some(false),
1251                        },
1252                        Frame::SubStart {
1253                            id: "tasks".into(),
1254                            status: SubStatus::Active,
1255                            reason_code: "".into(),
1256                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1257                            bootstrap: false,
1258                        },
1259                    ],
1260                };
1261                for (index, id) in ["first", "second"].iter().enumerate() {
1262                    let values = Map::from_iter([
1263                        ("id".into(), Value::from(*id)),
1264                        ("project_id".into(), Value::from("p1")),
1265                    ]);
1266                    response.frames.push(Frame::Commit {
1267                        commit_seq: index as i64 + 1,
1268                        actor_id: "writer".into(),
1269                        created_at_ms: 1,
1270                        tables: vec!["tasks".into()],
1271                        changes: vec![ssp2::model::Change {
1272                            table_index: 0,
1273                            row_id: (*id).into(),
1274                            op: Op::Upsert,
1275                            row_version: Some(index as i64 + 1),
1276                            scopes: vec![("project_id".into(), "p1".into())],
1277                            row: Some(
1278                                encode_row_json(
1279                                    client.schema.table("tasks").unwrap(),
1280                                    id,
1281                                    &values,
1282                                    &client.encryption,
1283                                )
1284                                .unwrap(),
1285                            ),
1286                        }],
1287                    });
1288                }
1289                response.frames.push(Frame::SubEnd {
1290                    next_cursor: 2,
1291                    bootstrap_state: None,
1292                });
1293                let valid = response.clone();
1294                match fault {
1295                    "row" => {
1296                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
1297                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
1298                    },
1299                    "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(),
1300                    "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(),
1301                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
1302                    "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});},
1303                    "missing-end" => {response.frames.truncate(3);},
1304                    _ => {}
1305                }
1306                if realtime {
1307                    client.on_realtime_binary(&mut transport, &encode_message(&response));
1308                } else {
1309                    assert_eq!(
1310                        matches!(
1311                            client.process_response(&mut transport, response, &meta),
1312                            SyncOutcome::Ok(_)
1313                        ),
1314                        fault == "none",
1315                        "{fault}"
1316                    );
1317                }
1318                assert!(
1319                    client.conn.is_autocommit(),
1320                    "no open transaction after {realtime}/{fault}"
1321                );
1322                // §1.4 rule 4: the deferred foreign key of the "commit" fault
1323                // fails the response's coalesced COMMIT, which rolls back both
1324                // frames; the other faults roll back only the failing frame.
1325                let count = match fault {
1326                    "none" | "cursor" => 2,
1327                    "commit" => 0,
1328                    _ => 1,
1329                };
1330                let expected_revision = count + u64::from(realtime && fault != "none");
1331                assert_eq!(
1332                    client.local_revision(),
1333                    expected_revision,
1334                    "{realtime}/{fault}"
1335                );
1336                let batches = client.drain_change_batches();
1337                assert_eq!(
1338                    batches.len(),
1339                    expected_revision as usize,
1340                    "{realtime}/{fault}"
1341                );
1342                let row_batches: Vec<_> = batches
1343                    .iter()
1344                    .filter(|batch| !batch.tables.is_empty())
1345                    .collect();
1346                assert_eq!(row_batches.len(), count as usize);
1347                assert_eq!(
1348                    row_batches
1349                        .iter()
1350                        .map(|b| b.revision.clone())
1351                        .collect::<Vec<_>>(),
1352                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1353                );
1354                for table in ["tasks", "_syncular_base_tasks"] {
1355                    assert_eq!(
1356                        client
1357                            .conn
1358                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1359                                .get::<_, u64>(0))
1360                            .unwrap(),
1361                        count,
1362                        "{realtime}/{fault}/{table}"
1363                    );
1364                }
1365                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1366                assert_eq!(
1367                    transport
1368                        .messages
1369                        .iter()
1370                        .filter(|m| m.contains("\"ack\""))
1371                        .count(),
1372                    usize::from(fault == "none")
1373                );
1374                if matches!(fault, "revision" | "commit" | "cursor") {
1375                    client
1376                        .conn
1377                        .execute_batch("DROP TRIGGER fail_frame")
1378                        .unwrap();
1379                }
1380                drop(client);
1381                let mut reopened = SyncClient::open_path(
1382                    "frame-test".into(),
1383                    &schema,
1384                    Default::default(),
1385                    path.to_str().unwrap(),
1386                )
1387                .unwrap();
1388                assert_eq!(reopened.local_revision(), expected_revision);
1389                assert_eq!(
1390                    reopened
1391                        .query("SELECT id FROM tasks ORDER BY id", &[])
1392                        .unwrap()
1393                        .len(),
1394                    count as usize
1395                );
1396                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1397                let (_, meta) = reopened.build_request(false).unwrap();
1398                assert!(matches!(
1399                    reopened.process_response(&mut transport, valid, &meta),
1400                    SyncOutcome::Ok(_)
1401                ));
1402                assert_eq!(
1403                    reopened
1404                        .query("SELECT id FROM tasks ORDER BY id", &[])
1405                        .unwrap()
1406                        .len(),
1407                    2
1408                );
1409                assert_eq!(reopened.subs[0].cursor, 2);
1410                drop(reopened);
1411                std::fs::remove_file(path).unwrap();
1412            }
1413        }
1414    }
1415
1416    #[test]
1417    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1418        for status in [SubStatus::Reset, SubStatus::Revoked] {
1419            for fault in ["subscription", "revision"] {
1420                let mut client = client();
1421                client
1422                    .subscribe(
1423                        "tasks".into(),
1424                        "tasks".into(),
1425                        vec![("project_id".into(), vec!["p1".into()])],
1426                        None,
1427                    )
1428                    .unwrap();
1429                client.subs[0].cursor = 41;
1430                client.subs[0].synced_once = true;
1431                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1432                client.persist_sub(&client.subs[0]).unwrap();
1433                client
1434                    .mutate(vec![Mutation::Upsert {
1435                        table: "tasks".into(),
1436                        values: Map::from_iter([
1437                            ("id".into(), Value::from("pending")),
1438                            ("project_id".into(), Value::from("p1")),
1439                        ]),
1440                        base_version: None,
1441                    }])
1442                    .unwrap();
1443                let ids = client.pending_commit_ids();
1444                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1445                let revision = client.local_revision();
1446                client.drain_change_batches();
1447                let (_, meta) = client.build_request(false).unwrap();
1448                client.conn.execute_batch(if fault == "subscription" {
1449                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1450                } else {
1451                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1452                }).unwrap();
1453                let mut transport = CountingRealtimeTransport::default();
1454                let mut report = SyncReport::default();
1455                // Reset changes window completeness and therefore needs a revision.
1456                if status == SubStatus::Reset {
1457                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1458                }
1459                let result = client.process_section(
1460                    &mut transport,
1461                    "tasks",
1462                    status,
1463                    "sync.scope_revoked",
1464                    vec![],
1465                    vec![],
1466                    Some((42, None)),
1467                    &meta,
1468                    &mut report,
1469                    &HashSet::new(),
1470                );
1471                assert!(result.is_err(), "{status:?}/{fault}");
1472                assert!(client.conn.is_autocommit());
1473                assert_eq!(client.subs[0].cursor, 41);
1474                assert_eq!(client.subs[0].state, SubState::Active);
1475                assert_eq!(client.pending_commit_ids(), ids);
1476                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1477                assert!(client.rejections.is_empty());
1478                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1479                assert_eq!(client.local_revision(), revision);
1480                assert!(client.drain_change_batches().is_empty());
1481                assert!(report.revoked.is_empty() && report.resets.is_empty());
1482            }
1483        }
1484    }
1485
1486    #[test]
1487    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1488        let mut client = client();
1489        client
1490            .subscribe(
1491                "tasks".into(),
1492                "tasks".into(),
1493                vec![("project_id".into(), vec!["p1".into()])],
1494                None,
1495            )
1496            .unwrap();
1497        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1498        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1499            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1500        let row = |id: &str| ssp2::segment::SegmentRow {
1501            server_version: 1,
1502            values: vec![
1503                Some(ColumnValue::String(id.into())),
1504                Some(ColumnValue::String("p1".into())),
1505            ],
1506        };
1507        let segment = RowsSegment {
1508            table: "tasks".into(),
1509            schema_version: 1,
1510            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1511            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1512        };
1513        assert!(client.apply_segment(0, &segment, true).is_err());
1514        assert!(client.conn.is_autocommit());
1515        assert_eq!(
1516            client
1517                .query("SELECT id FROM tasks ORDER BY id", &[])
1518                .unwrap(),
1519            vec![Map::from_iter([("id".into(), Value::from("first"))])]
1520        );
1521        assert_eq!(client.local_revision(), 1);
1522        assert_eq!(client.drain_change_batches().len(), 1);
1523        assert_eq!(client.subs[0].cursor, -1);
1524        client
1525            .conn
1526            .execute_batch("DROP TRIGGER fail_block")
1527            .unwrap();
1528        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1529        assert_eq!(client.local_revision(), 3);
1530        assert_eq!(client.drain_change_batches().len(), 2);
1531    }
1532
1533    /// Serves one response and records the segment batch it causes. A single
1534    /// `download_segment` call means the core fell back to per-descriptor
1535    /// fetches.
1536    struct SegmentBatchTransport {
1537        response: Message,
1538        bodies: BTreeMap<String, Vec<u8>>,
1539        batches: Vec<Vec<String>>,
1540        single_fetches: usize,
1541    }
1542
1543    impl Transport for SegmentBatchTransport {
1544        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1545            Ok(encode_message(&self.response))
1546        }
1547
1548        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1549            self.sync(request)
1550        }
1551
1552        fn download_segment(
1553            &mut self,
1554            _request: &SegmentRequest,
1555            _on_progress: &mut dyn FnMut(u64),
1556        ) -> Result<Vec<u8>, TransportError> {
1557            self.single_fetches += 1;
1558            Err(TransportError::new(
1559                "sync.transport_failed",
1560                "per-descriptor fetch",
1561            ))
1562        }
1563
1564        fn download_segments(
1565            &mut self,
1566            fetches: &[crate::SegmentFetch],
1567            on_progress: &mut (dyn FnMut(usize, u64) + Send),
1568        ) -> Vec<Result<Vec<u8>, TransportError>> {
1569            self.batches.push(
1570                fetches
1571                    .iter()
1572                    .map(|fetch| match fetch {
1573                        crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
1574                        crate::SegmentFetch::Url(url) => url.clone(),
1575                    })
1576                    .collect(),
1577            );
1578            fetches
1579                .iter()
1580                .enumerate()
1581                .map(|(index, fetch)| {
1582                    let crate::SegmentFetch::Direct(request) = fetch else {
1583                        panic!("the descriptors are direct-endpoint requests")
1584                    };
1585                    let bytes = self.bodies[&request.segment_id].clone();
1586                    on_progress(index, bytes.len() as u64);
1587                    Ok(bytes)
1588                })
1589                .collect()
1590        }
1591
1592        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1593            Ok(())
1594        }
1595
1596        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1597            Ok(())
1598        }
1599
1600        fn realtime_close(&mut self) -> Result<(), TransportError> {
1601            Ok(())
1602        }
1603    }
1604
1605    #[test]
1606    fn a_response_fetches_all_its_segment_pages_in_one_ordered_batch() {
1607        let mut client = client();
1608        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1609        client
1610            .subscribe(
1611                "tasks".into(),
1612                "tasks".into(),
1613                vec![("project_id".into(), vec!["p1".into()])],
1614                None,
1615            )
1616            .unwrap();
1617        client.subs[0].cursor = 0;
1618        client.subs[0].synced_once = true;
1619        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1620        client.persist_sub(&client.subs[0]).unwrap();
1621        client.drain_change_batches();
1622        let columns = client.schema.table("tasks").unwrap().wire_columns.clone();
1623
1624        // Four pages, one row each. Applying a page under another page's
1625        // descriptor fails §5.1 content-address verification, so a green round
1626        // proves the results kept the wire order.
1627        let pages = ["one", "two", "three", "four"].map(|id| {
1628            let bytes = ssp2::segment::encode_rows_segment(&RowsSegment {
1629                table: "tasks".into(),
1630                schema_version: 1,
1631                columns: columns.clone(),
1632                blocks: vec![vec![ssp2::segment::SegmentRow {
1633                    server_version: 1,
1634                    values: vec![
1635                        Some(ColumnValue::String(id.into())),
1636                        Some(ColumnValue::String("p1".into())),
1637                    ],
1638                }]],
1639            });
1640            (
1641                format!("sha256:{}", bytes_to_hex(&Sha256::digest(&bytes))),
1642                bytes,
1643            )
1644        });
1645
1646        let mut frames = vec![
1647            Frame::RespHeader {
1648                required_schema_version: None,
1649                latest_schema_version: None,
1650                log_epoch: Some("epoch-1".into()),
1651                reset_required: Some(false),
1652            },
1653            Frame::SubStart {
1654                id: "tasks".into(),
1655                status: SubStatus::Active,
1656                reason_code: String::new(),
1657                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1658                bootstrap: false,
1659            },
1660        ];
1661        for (segment_id, bytes) in &pages {
1662            frames.push(Frame::SegmentRef {
1663                segment_id: segment_id.clone(),
1664                media_type: MediaType::Rows,
1665                table: "tasks".into(),
1666                byte_length: bytes.len() as i64,
1667                row_count: 1,
1668                as_of_commit_seq: 1,
1669                scope_digest: "digest".into(),
1670                row_cursor: None,
1671                next_row_cursor: None,
1672                url: None,
1673                url_expires_at_ms: None,
1674            });
1675        }
1676        frames.push(Frame::SubEnd {
1677            next_cursor: 1,
1678            bootstrap_state: None,
1679        });
1680        let mut transport = SegmentBatchTransport {
1681            response: Message {
1682                wire_version: WIRE_VERSION,
1683                msg_kind: MsgKind::Response,
1684                frames,
1685            },
1686            bodies: pages
1687                .iter()
1688                .map(|(segment_id, bytes)| (segment_id.clone(), bytes.clone()))
1689                .collect(),
1690            batches: Vec::new(),
1691            single_fetches: 0,
1692        };
1693
1694        let outcome = client.sync(&mut transport);
1695        assert!(matches!(outcome, SyncOutcome::Ok(_)), "{outcome:?}");
1696        assert_eq!(
1697            transport.batches,
1698            vec![pages
1699                .iter()
1700                .map(|(segment_id, _)| segment_id.clone())
1701                .collect::<Vec<_>>()],
1702            "one batch per response, in wire order"
1703        );
1704        assert_eq!(
1705            transport.single_fetches, 0,
1706            "the batch seam replaces the per-descriptor calls"
1707        );
1708        assert_eq!(
1709            client
1710                .query("SELECT id FROM tasks ORDER BY id", &[])
1711                .unwrap(),
1712            ["four", "one", "three", "two"]
1713                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
1714                .to_vec()
1715        );
1716        assert_eq!(client.subs[0].cursor, 1);
1717    }
1718
1719    #[test]
1720    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1721        assert_eq!(
1722            client().diagnostics_storage().blob_cache_bytes_approx,
1723            Some(0)
1724        );
1725        let client = SyncClient::new(
1726            "diagnostic-storage".to_owned(),
1727            &json!({
1728                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1729                    "columns": [{"name": "id", "type": "string", "nullable": false},
1730                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1731            }),
1732            ClientLimits {
1733                blob_cache_max_bytes: Some(5),
1734                ..Default::default()
1735            },
1736        )
1737        .unwrap();
1738        let empty = client.diagnostics_storage();
1739        assert_eq!(empty.status, "healthy");
1740        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1741        assert_eq!(empty.retained_outcome_entries, Some(0));
1742        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1743        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1744        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1745            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1746                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1747            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1748                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1749        let written = client.diagnostics_storage();
1750        assert_eq!(written.status, "pressure");
1751        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1752        assert_eq!(written.retained_outcome_entries, Some(2));
1753        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1754        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1755        assert_eq!(
1756            written.pressure_reason_code.as_deref(),
1757            Some("client.blob_cache_over_limit")
1758        );
1759        client
1760            .conn
1761            .execute_batch(
1762                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1763            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1764            )
1765            .unwrap();
1766        let staged = client.diagnostics_storage();
1767        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1768        assert_eq!(staged.retained_outcome_entries, Some(0));
1769        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1770        assert_eq!(staged.status, "healthy");
1771        client.conn.execute_batch("ROLLBACK").unwrap();
1772        assert_eq!(client.diagnostics_storage(), written);
1773        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1774        let unreadable = client.diagnostics_storage();
1775        assert_eq!(unreadable.status, "unreadable");
1776        assert!(unreadable.database_bytes_approx.is_none());
1777        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1778        assert!(unreadable.retained_outcome_entries.is_none());
1779        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1780        assert!(unreadable.blob_cache_bytes_approx.is_none());
1781        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1782        let restored = client.diagnostics_storage();
1783        assert_eq!(restored.status, written.status);
1784        assert_eq!(
1785            restored.retained_outcome_bytes_approx,
1786            written.retained_outcome_bytes_approx
1787        );
1788        client.conn.set_prepared_statement_cache_capacity(0);
1789        assert_eq!(client.diagnostics_storage(), restored);
1790    }
1791
1792    /// Answers every round with one server `ERROR` frame (§1.6).
1793    struct ServerErrorTransport {
1794        code: &'static str,
1795        retryable: bool,
1796    }
1797
1798    impl Transport for ServerErrorTransport {
1799        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1800            Ok(encode_message(&Message {
1801                wire_version: WIRE_VERSION,
1802                msg_kind: MsgKind::Response,
1803                frames: vec![
1804                    Frame::RespHeader {
1805                        required_schema_version: None,
1806                        latest_schema_version: None,
1807                        log_epoch: Some("epoch-1".to_owned()),
1808                        reset_required: Some(false),
1809                    },
1810                    Frame::Error {
1811                        code: self.code.to_owned(),
1812                        message: "server error".to_owned(),
1813                        category: "internal".to_owned(),
1814                        retryable: self.retryable,
1815                        recommended_action: "retryLater".to_owned(),
1816                        details: None,
1817                    },
1818                ],
1819            }))
1820        }
1821
1822        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1823            self.sync(request)
1824        }
1825
1826        fn download_segment(
1827            &mut self,
1828            _request: &SegmentRequest,
1829            _on_progress: &mut dyn FnMut(u64),
1830        ) -> Result<Vec<u8>, TransportError> {
1831            Err(TransportError::new("sync.transport_failed", "offline"))
1832        }
1833
1834        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1835            Ok(())
1836        }
1837
1838        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1839            Ok(())
1840        }
1841
1842        fn realtime_close(&mut self) -> Result<(), TransportError> {
1843            Ok(())
1844        }
1845    }
1846
1847    #[test]
1848    fn a_retryable_in_band_failure_keeps_the_socket_connected_and_still_asks_for_a_retry() {
1849        let mut client = client();
1850        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1851        let mut transport = ServerErrorTransport {
1852            code: "sync.schema_not_ready",
1853            retryable: true,
1854        };
1855        client.connect_realtime(&mut transport).expect("connect");
1856        assert!(matches!(
1857            client.sync(&mut transport),
1858            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.schema_not_ready"
1859        ));
1860        assert_eq!(
1861            client.realtime_state(),
1862            RealtimeState::Connected,
1863            "the socket delivered the answer, so it is not lost"
1864        );
1865        assert!(
1866            matches!(
1867                client.drain_sync_intents().as_slice(),
1868                [SyncIntent::Background { delay_ms: 250 }]
1869            ),
1870            "the retry deadline is a sync round and no reconnect satisfies it"
1871        );
1872        assert_eq!(
1873            client.progress().snapshot().unwrap().retry_delay_ms,
1874            Some(250)
1875        );
1876    }
1877
1878    #[test]
1879    fn retryable_server_error_schedules_a_background_retry() {
1880        let mut client = client();
1881        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1882        let mut transport = ServerErrorTransport {
1883            code: "sync.internal_error",
1884            retryable: true,
1885        };
1886        assert!(matches!(
1887            client.sync(&mut transport),
1888            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1889        ));
1890        assert!(matches!(
1891            client.drain_sync_intents().as_slice(),
1892            [SyncIntent::Background { delay_ms: 250 }]
1893        ));
1894        assert_eq!(
1895            client.progress().snapshot().unwrap().retry_delay_ms,
1896            Some(250)
1897        );
1898        let mut transport = ServerErrorTransport {
1899            code: "sync.invalid_request",
1900            retryable: false,
1901        };
1902        assert!(matches!(
1903            client.sync(&mut transport),
1904            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1905        ));
1906        assert!(client.drain_sync_intents().is_empty());
1907        let progress = client.progress().snapshot().unwrap();
1908        assert_eq!(progress.state, ProgressState::Failed);
1909        assert_eq!(progress.retry_delay_ms, None);
1910    }
1911
1912    #[test]
1913    fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1914        // §7.7: the exhausted outcome is a partial success, not a failure,
1915        // and serializes with an explicit flag.
1916        let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1917        assert_eq!(exhausted["ok"], json!(true));
1918        assert_eq!(exhausted["budgetExhausted"], json!(true));
1919        assert!(exhausted["report"].is_object());
1920
1921        let mut client = client();
1922        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1923        // Zero explicit budget is a caller error, not an exhaustion.
1924        let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1925        let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1926        assert!(
1927            matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1928            "{zero:?}"
1929        );
1930        // A real server failure stays a failure across the budget loop.
1931        let mut erroring = ServerErrorTransport {
1932            code: "sync.internal_error",
1933            retryable: false,
1934        };
1935        let failed = client.sync_until_idle(&mut erroring, Some(3));
1936        assert!(
1937            matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1938            "{failed:?}"
1939        );
1940    }
1941
1942    /// Returns one pre-built response per round so a test can pin a successful
1943    /// work-bearing round followed by a failure or a schema floor.
1944    struct ScriptedSyncTransport {
1945        responses: std::collections::VecDeque<Message>,
1946        calls: usize,
1947    }
1948
1949    impl Transport for ScriptedSyncTransport {
1950        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1951            self.calls += 1;
1952            let response = self.responses.pop_front().expect("scripted response");
1953            Ok(encode_message(&response))
1954        }
1955
1956        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1957            self.sync(request)
1958        }
1959
1960        fn download_segment(
1961            &mut self,
1962            _request: &SegmentRequest,
1963            _on_progress: &mut dyn FnMut(u64),
1964        ) -> Result<Vec<u8>, TransportError> {
1965            Err(TransportError::new("sync.transport_failed", "offline"))
1966        }
1967
1968        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1969            Ok(())
1970        }
1971
1972        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1973            Ok(())
1974        }
1975
1976        fn realtime_close(&mut self) -> Result<(), TransportError> {
1977            Ok(())
1978        }
1979    }
1980
1981    #[test]
1982    fn sync_until_idle_success_then_failure_keeps_earlier_work() {
1983        let path = std::env::temp_dir().join(format!(
1984            "syncular-budget-script-{}.sqlite",
1985            uuid::Uuid::new_v4()
1986        ));
1987        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1988            {"name":"id","type":"string","nullable":false},
1989            {"name":"project_id","type":"string","nullable":false}],
1990            "scopes":[{"pattern":"project:{project_id}"}]}]});
1991        let mut client = SyncClient::open_path(
1992            "budget-script".into(),
1993            &schema,
1994            ClientLimits::default(),
1995            path.to_str().unwrap(),
1996        )
1997        .expect("open client");
1998        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1999        client
2000            .subscribe(
2001                "tasks".into(),
2002                "tasks".into(),
2003                vec![("project_id".into(), vec!["p1".into()])],
2004                None,
2005            )
2006            .unwrap();
2007        client.subs[0].cursor = 0;
2008        client.subs[0].synced_once = true;
2009        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2010        client.persist_sub(&client.subs[0]).unwrap();
2011        client.drain_change_batches();
2012
2013        let row = encode_row_json(
2014            client.schema.table("tasks").unwrap(),
2015            "kept",
2016            &Map::from_iter([
2017                ("id".into(), json!("kept")),
2018                ("project_id".into(), json!("p1")),
2019            ]),
2020            &client.encryption,
2021        )
2022        .unwrap();
2023        let success = Message {
2024            wire_version: WIRE_VERSION,
2025            msg_kind: MsgKind::Response,
2026            frames: vec![
2027                Frame::RespHeader {
2028                    required_schema_version: None,
2029                    latest_schema_version: None,
2030                    log_epoch: Some("epoch-1".into()),
2031                    reset_required: Some(false),
2032                },
2033                Frame::SubStart {
2034                    id: "tasks".into(),
2035                    status: SubStatus::Active,
2036                    reason_code: String::new(),
2037                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2038                    bootstrap: false,
2039                },
2040                Frame::Commit {
2041                    commit_seq: 1,
2042                    created_at_ms: 1,
2043                    actor_id: "writer".into(),
2044                    tables: vec!["tasks".into()],
2045                    changes: vec![ssp2::model::Change {
2046                        table_index: 0,
2047                        row_id: "kept".into(),
2048                        op: Op::Upsert,
2049                        row_version: Some(1),
2050                        scopes: vec![("project_id".into(), "p1".into())],
2051                        row: Some(row),
2052                    }],
2053                },
2054                Frame::SubEnd {
2055                    next_cursor: 1,
2056                    bootstrap_state: None,
2057                },
2058            ],
2059        };
2060        let failure = Message {
2061            wire_version: WIRE_VERSION,
2062            msg_kind: MsgKind::Response,
2063            frames: vec![
2064                Frame::RespHeader {
2065                    required_schema_version: None,
2066                    latest_schema_version: None,
2067                    log_epoch: Some("epoch-1".into()),
2068                    reset_required: Some(false),
2069                },
2070                Frame::Error {
2071                    code: "sync.internal_error".into(),
2072                    message: "second round failed".into(),
2073                    category: "internal".into(),
2074                    retryable: false,
2075                    recommended_action: "retryLater".into(),
2076                    details: None,
2077                },
2078            ],
2079        };
2080        let mut transport = ScriptedSyncTransport {
2081            responses: std::collections::VecDeque::from([success, failure]),
2082            calls: 0,
2083        };
2084        let outcome = client.sync_until_idle(&mut transport, Some(2));
2085        let SyncOutcome::Failed {
2086            error_code,
2087            recommended_action,
2088            ..
2089        } = outcome
2090        else {
2091            panic!("expected the in-band failure");
2092        };
2093        assert_eq!(error_code, "sync.internal_error");
2094        assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2095        assert_eq!(
2096            transport.calls, 2,
2097            "one successful round then the failing round"
2098        );
2099        assert_eq!(
2100            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2101            json!("kept"),
2102            "the earlier round's durable work survives the later failure"
2103        );
2104        drop(client);
2105        let _ = std::fs::remove_file(&path);
2106    }
2107
2108    #[test]
2109    fn sync_until_idle_stops_on_a_schema_floor() {
2110        let mut client = client();
2111        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2112        let floor = Message {
2113            wire_version: WIRE_VERSION,
2114            msg_kind: MsgKind::Response,
2115            frames: vec![Frame::RespHeader {
2116                required_schema_version: Some(2),
2117                latest_schema_version: Some(2),
2118                log_epoch: Some("epoch-1".into()),
2119                reset_required: Some(false),
2120            }],
2121        };
2122        let mut transport = ScriptedSyncTransport {
2123            responses: std::collections::VecDeque::from([floor.clone(), floor]),
2124            calls: 0,
2125        };
2126        let outcome = client.sync_until_idle(&mut transport, Some(3));
2127        match outcome {
2128            SyncOutcome::Ok(report) => assert_eq!(
2129                report
2130                    .schema_floor
2131                    .as_ref()
2132                    .and_then(|floor| floor.required_schema_version),
2133                Some(2),
2134                "the stop retains the latest schema floor"
2135            ),
2136            other => panic!("expected a schema-floor stop, got {other:?}"),
2137        }
2138        assert_eq!(
2139            transport.calls, 1,
2140            "a schema floor stops the budget loop after one round"
2141        );
2142    }
2143
2144    /// Records the decoded request of every round and answers from a script of
2145    /// round results, so a test can pin what the following request carries.
2146    struct FailureScriptTransport {
2147        rounds: std::collections::VecDeque<Result<Vec<u8>, TransportError>>,
2148        requests: Vec<Message>,
2149    }
2150
2151    impl Transport for FailureScriptTransport {
2152        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2153            self.requests
2154                .push(decode_message(request).expect("the client encodes a legal request"));
2155            self.rounds.pop_front().expect("scripted round")
2156        }
2157
2158        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2159            self.sync(request)
2160        }
2161
2162        fn download_segment(
2163            &mut self,
2164            _request: &SegmentRequest,
2165            _on_progress: &mut dyn FnMut(u64),
2166        ) -> Result<Vec<u8>, TransportError> {
2167            Err(TransportError::new("transport.timeout", "segment stalled"))
2168        }
2169
2170        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2171            Ok(())
2172        }
2173
2174        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2175            Ok(())
2176        }
2177
2178        fn realtime_close(&mut self) -> Result<(), TransportError> {
2179            Ok(())
2180        }
2181    }
2182
2183    /// A complete response: RESP_HEADER for the client's epoch plus `frames`.
2184    fn completed_response(frames: Vec<Frame>) -> Vec<u8> {
2185        let mut all = vec![Frame::RespHeader {
2186            required_schema_version: None,
2187            latest_schema_version: None,
2188            log_epoch: Some("epoch-1".into()),
2189            reset_required: Some(false),
2190        }];
2191        all.extend(frames);
2192        encode_message(&Message {
2193            wire_version: WIRE_VERSION,
2194            msg_kind: MsgKind::Response,
2195            frames: all,
2196        })
2197    }
2198
2199    fn carried_round_failures(request: &Message) -> Vec<String> {
2200        request
2201            .frames
2202            .iter()
2203            .filter_map(|frame| match frame {
2204                Frame::RoundFailure { code } => Some(code.clone()),
2205                _ => None,
2206            })
2207            .collect()
2208    }
2209
2210    fn failure_transport(
2211        rounds: impl IntoIterator<Item = Result<Vec<u8>, TransportError>>,
2212    ) -> FailureScriptTransport {
2213        FailureScriptTransport {
2214            rounds: rounds.into_iter().collect(),
2215            requests: Vec::new(),
2216        }
2217    }
2218
2219    #[test]
2220    fn a_round_without_a_complete_response_reports_its_code_on_every_following_request() {
2221        let mut client = client();
2222        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2223        let mut transport = failure_transport([
2224            Err(TransportError::new("transport.timeout", "round timed out")),
2225            Ok(completed_response(Vec::new())),
2226            Ok(completed_response(Vec::new())),
2227        ]);
2228        for _ in 0..3 {
2229            client.sync(&mut transport);
2230        }
2231        assert!(carried_round_failures(&transport.requests[0]).is_empty());
2232        assert_eq!(
2233            carried_round_failures(&transport.requests[1]),
2234            vec!["transport.timeout"]
2235        );
2236        assert!(
2237            matches!(transport.requests[1].frames[0], Frame::ReqHeader { .. })
2238                && matches!(transport.requests[1].frames[1], Frame::RoundFailure { .. }),
2239            "the report sits immediately after REQ_HEADER"
2240        );
2241        assert!(
2242            carried_round_failures(&transport.requests[2]).is_empty(),
2243            "a complete response ends the report"
2244        );
2245    }
2246
2247    #[test]
2248    fn a_newer_round_failure_replaces_the_kept_code() {
2249        let mut client = client();
2250        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2251        let mut transport = failure_transport([
2252            Err(TransportError::new("transport.timeout", "round timed out")),
2253            Err(TransportError::new("transport.failed", "socket closed")),
2254            Ok(completed_response(Vec::new())),
2255            Ok(completed_response(Vec::new())),
2256        ]);
2257        for _ in 0..4 {
2258            client.sync(&mut transport);
2259        }
2260        assert_eq!(
2261            carried_round_failures(&transport.requests[1]),
2262            vec!["transport.timeout"]
2263        );
2264        assert_eq!(
2265            carried_round_failures(&transport.requests[2]),
2266            vec!["transport.failed"]
2267        );
2268        assert!(carried_round_failures(&transport.requests[3]).is_empty());
2269    }
2270
2271    #[test]
2272    fn an_in_band_error_completes_the_round_and_is_never_reported() {
2273        let mut client = client();
2274        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2275        let mut transport = failure_transport([
2276            Err(TransportError::new("transport.timeout", "round timed out")),
2277            Ok(completed_response(vec![Frame::Error {
2278                code: "sync.internal_error".into(),
2279                message: "server error".into(),
2280                category: "internal".into(),
2281                retryable: false,
2282                recommended_action: "retryLater".into(),
2283                details: None,
2284            }])),
2285            Ok(completed_response(Vec::new())),
2286        ]);
2287        client.sync(&mut transport);
2288        assert!(matches!(
2289            client.sync(&mut transport),
2290            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
2291        ));
2292        client.sync(&mut transport);
2293        assert_eq!(
2294            carried_round_failures(&transport.requests[1]),
2295            vec!["transport.timeout"]
2296        );
2297        assert!(
2298            carried_round_failures(&transport.requests[2]).is_empty(),
2299            "the server already recorded an in-band ERROR"
2300        );
2301    }
2302
2303    #[test]
2304    fn a_silent_segment_fetch_is_retryable_and_reported_on_the_next_request() {
2305        let mut client = client();
2306        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2307        client
2308            .subscribe(
2309                "tasks".into(),
2310                "tasks".into(),
2311                vec![("project_id".into(), vec!["p1".into()])],
2312                None,
2313            )
2314            .unwrap();
2315        client.subs[0].cursor = 0;
2316        client.subs[0].synced_once = true;
2317        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2318        client.persist_sub(&client.subs[0]).unwrap();
2319        client.drain_change_batches();
2320        client.drain_sync_intents();
2321        let mut transport = failure_transport([
2322            Ok(completed_response(vec![
2323                Frame::SubStart {
2324                    id: "tasks".into(),
2325                    status: SubStatus::Active,
2326                    reason_code: String::new(),
2327                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2328                    bootstrap: false,
2329                },
2330                Frame::SegmentRef {
2331                    segment_id: "sha256:stalled".into(),
2332                    media_type: MediaType::Rows,
2333                    table: "tasks".into(),
2334                    byte_length: 1,
2335                    row_count: 1,
2336                    as_of_commit_seq: 1,
2337                    scope_digest: "digest".into(),
2338                    row_cursor: None,
2339                    next_row_cursor: None,
2340                    url: None,
2341                    url_expires_at_ms: None,
2342                },
2343                Frame::SubEnd {
2344                    next_cursor: 1,
2345                    bootstrap_state: None,
2346                },
2347            ])),
2348            Ok(completed_response(Vec::new())),
2349            Ok(completed_response(Vec::new())),
2350        ]);
2351
2352        assert!(matches!(
2353            client.sync(&mut transport),
2354            SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
2355        ));
2356        assert!(
2357            matches!(
2358                client.drain_sync_intents().as_slice(),
2359                [SyncIntent::Background { delay_ms: 250 }]
2360            ),
2361            "a silent transfer asks the host for a Background retry"
2362        );
2363        client.sync(&mut transport);
2364        client.sync(&mut transport);
2365        assert!(carried_round_failures(&transport.requests[0]).is_empty());
2366        assert_eq!(
2367            carried_round_failures(&transport.requests[1]),
2368            vec!["transport.timeout"],
2369            "the round's own response was complete, so the fetch failure rides the next request"
2370        );
2371        assert!(
2372            carried_round_failures(&transport.requests[2]).is_empty(),
2373            "a complete response ends the report"
2374        );
2375    }
2376
2377    #[test]
2378    fn a_code_the_frame_grammar_rejects_is_not_attached() {
2379        let mut client = client();
2380        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2381        let mut transport = failure_transport([
2382            Err(TransportError::new("offline", "no dotted code")),
2383            Ok(completed_response(Vec::new())),
2384        ]);
2385        client.sync(&mut transport);
2386        client.sync(&mut transport);
2387        assert!(carried_round_failures(&transport.requests[1]).is_empty());
2388    }
2389
2390    #[test]
2391    fn sync_report_merge_schema_floor_is_latest_state() {
2392        let mut aggregate = SyncReport {
2393            schema_floor: Some(SchemaFloor {
2394                required_schema_version: Some(2),
2395                latest_schema_version: Some(2),
2396            }),
2397            ..SyncReport::default()
2398        };
2399        aggregate.merge(&SyncReport::default());
2400        assert!(
2401            aggregate.schema_floor.is_none(),
2402            "a later round without a floor clears it"
2403        );
2404    }
2405
2406    #[test]
2407    fn push_capacity_limits_are_hard_and_keep_intent() {
2408        // The configured operation cap is hard for the first commit.
2409        let mut c = client();
2410        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2411        c.limits.max_push_operations_per_request = Some(1);
2412        let id = c
2413            .mutate(vec![
2414                Mutation::Upsert {
2415                    table: "tasks".into(),
2416                    values: Map::from_iter([
2417                        ("id".into(), json!("a")),
2418                        ("project_id".into(), json!("p1")),
2419                    ]),
2420                    base_version: None,
2421                },
2422                Mutation::Upsert {
2423                    table: "tasks".into(),
2424                    values: Map::from_iter([
2425                        ("id".into(), json!("b")),
2426                        ("project_id".into(), json!("p1")),
2427                    ]),
2428                    base_version: None,
2429                },
2430            ])
2431            .unwrap();
2432        let error = c.build_request(false).unwrap_err();
2433        let SyncOutcome::Failed {
2434            error_code,
2435            details,
2436            ..
2437        } = *error
2438        else {
2439            panic!("expected a capacity failure");
2440        };
2441        assert_eq!(error_code, "client.push_request_too_large");
2442        let details = details.expect("capacity details");
2443        assert_eq!(details["kind"], json!("operations"));
2444        assert_eq!(details["limit"], json!(1));
2445        assert_eq!(details["size"], json!(2));
2446        assert_eq!(details["clientCommitId"], json!(id));
2447        assert_eq!(c.pending_commit_ids(), vec![id]);
2448
2449        // The commit-count cap defers whole later commits.
2450        let mut c = client();
2451        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2452        c.limits.max_push_commits_per_request = Some(1);
2453        let first = c
2454            .mutate(vec![Mutation::Upsert {
2455                table: "tasks".into(),
2456                values: Map::from_iter([
2457                    ("id".into(), json!("one")),
2458                    ("project_id".into(), json!("p1")),
2459                ]),
2460                base_version: None,
2461            }])
2462            .unwrap();
2463        let second = c
2464            .mutate(vec![Mutation::Upsert {
2465                table: "tasks".into(),
2466                values: Map::from_iter([
2467                    ("id".into(), json!("two")),
2468                    ("project_id".into(), json!("p1")),
2469                ]),
2470                base_version: None,
2471            }])
2472            .unwrap();
2473        let (_, meta) = c.build_request(false).unwrap();
2474        assert_eq!(meta.pushed_ids, vec![first.clone()]);
2475        assert_eq!(meta.deferred_commits, 1);
2476        assert_eq!(c.pending_commit_ids(), vec![first, second]);
2477    }
2478
2479    #[test]
2480    fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
2481        // Two small commits: a cap just below the full request defers the
2482        // last commit and sends the fitting prefix (header + first commit).
2483        let mut c = client();
2484        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2485        let first = c
2486            .mutate(vec![Mutation::Upsert {
2487                table: "tasks".into(),
2488                values: Map::from_iter([
2489                    ("id".into(), json!("one")),
2490                    ("project_id".into(), json!("p1")),
2491                ]),
2492                base_version: None,
2493            }])
2494            .unwrap();
2495        let second = c
2496            .mutate(vec![Mutation::Upsert {
2497                table: "tasks".into(),
2498                values: Map::from_iter([
2499                    ("id".into(), json!("two")),
2500                    ("project_id".into(), json!("p1")),
2501                ]),
2502                base_version: None,
2503            }])
2504            .unwrap();
2505        let (message, meta) = c.build_request(false).unwrap();
2506        assert_eq!(meta.pushed_ids.len(), 2);
2507        let full = encode_message(&message).len();
2508        c.limits.max_push_request_bytes = Some(full - 1);
2509        let (_, meta) = c.build_request(false).unwrap();
2510        assert_eq!(meta.pushed_ids, vec![first.clone()]);
2511        assert_eq!(meta.deferred_commits, 1);
2512        assert_eq!(c.pending_commit_ids(), vec![first, second]);
2513
2514        // A single commit larger than the budget is a typed capacity error and
2515        // stays queued, reporting the full projected request size.
2516        let mut c = client();
2517        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2518        let huge = c
2519            .mutate(vec![Mutation::Upsert {
2520                table: "tasks".into(),
2521                values: Map::from_iter([
2522                    ("id".into(), json!("x".repeat(20_000))),
2523                    ("project_id".into(), json!("p1")),
2524                ]),
2525                base_version: None,
2526            }])
2527            .unwrap();
2528        let (message, meta) = c.build_request(false).unwrap();
2529        assert_eq!(meta.pushed_ids, vec![huge.clone()]);
2530        let with_huge = encode_message(&message).len();
2531        c.limits.max_push_request_bytes = Some(with_huge - 1);
2532        let error = c.build_request(false).unwrap_err();
2533        let SyncOutcome::Failed {
2534            error_code,
2535            details,
2536            ..
2537        } = *error
2538        else {
2539            panic!("expected a capacity failure");
2540        };
2541        assert_eq!(error_code, "client.push_request_too_large");
2542        let details = details.expect("capacity details");
2543        assert_eq!(details["kind"], json!("bytes"));
2544        assert_eq!(details["limit"], json!((with_huge - 1) as u64));
2545        assert_eq!(details["size"], json!(with_huge as u64));
2546        assert_eq!(details["clientCommitId"], json!(huge));
2547        assert_eq!(c.pending_commit_ids(), vec![huge]);
2548
2549        // A cap smaller than the fixed frames reports no blocked commit.
2550        let mut c = client();
2551        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2552        c.limits.max_push_request_bytes = Some(1);
2553        let error = c.build_request(false).unwrap_err();
2554        let SyncOutcome::Failed { details, .. } = *error else {
2555            panic!("expected a capacity failure");
2556        };
2557        let details = details.expect("capacity details");
2558        assert_eq!(details["kind"], json!("bytes"));
2559        assert!(details.get("clientCommitId").is_none(), "{details}");
2560    }
2561
2562    #[test]
2563    fn push_limits_validate_at_construction() {
2564        for limits in [
2565            ClientLimits {
2566                max_push_commits_per_request: Some(0),
2567                ..Default::default()
2568            },
2569            ClientLimits {
2570                max_push_operations_per_request: Some(0),
2571                ..Default::default()
2572            },
2573            ClientLimits {
2574                max_push_request_bytes: Some(0),
2575                ..Default::default()
2576            },
2577            ClientLimits {
2578                max_push_request_bytes: Some(u32::MAX as usize + 1),
2579                ..Default::default()
2580            },
2581        ] {
2582            let schema = json!({"version":1,"tables":[]});
2583            let error = SyncClient::with_connection(
2584                "bad-limits".into(),
2585                &schema,
2586                limits,
2587                Connection::open_in_memory().unwrap(),
2588            )
2589            .err();
2590            assert_eq!(
2591                error.as_deref(),
2592                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2593            );
2594        }
2595    }
2596
2597    #[test]
2598    fn open_path_rejects_invalid_push_limits_before_storage() {
2599        for identity in [false, true] {
2600            let path = std::env::temp_dir().join(format!(
2601                "syncular-bad-limits-{}.sqlite",
2602                uuid::Uuid::new_v4()
2603            ));
2604            let schema = json!({"version":1,"tables":[]});
2605            let limits = ClientLimits {
2606                max_push_request_bytes: Some(0),
2607                ..Default::default()
2608            };
2609            let error = if identity {
2610                SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2611            } else {
2612                SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2613            }
2614            .err();
2615            assert_eq!(
2616                error.as_deref(),
2617                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2618            );
2619            assert!(
2620                !path.exists(),
2621                "an invalid limit is rejected before the database file is created"
2622            );
2623        }
2624    }
2625
2626    #[test]
2627    fn failed_progress_retry_delay_doubles_to_the_cap() {
2628        let mut client = client();
2629        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2630        let mut transport = ServerErrorTransport {
2631            code: "sync.internal_error",
2632            retryable: true,
2633        };
2634        let delays: Vec<Option<u64>> = (0..9)
2635            .map(|_| {
2636                client.sync(&mut transport);
2637                client.progress().snapshot().unwrap().retry_delay_ms
2638            })
2639            .collect();
2640        assert_eq!(
2641            delays,
2642            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2643        );
2644    }
2645
2646    #[test]
2647    fn background_retry_deadlines_back_off_and_reset() {
2648        let mut client = client();
2649        client.schedule_background_retry();
2650        assert!(matches!(
2651            client.drain_sync_intents().as_slice(),
2652            [SyncIntent::Background { delay_ms: 250 }]
2653        ));
2654        client.schedule_background_retry();
2655        assert!(matches!(
2656            client.drain_sync_intents().as_slice(),
2657            [SyncIntent::Background { delay_ms: 500 }]
2658        ));
2659        client.reset_background_retry();
2660        client.schedule_background_retry();
2661        assert!(matches!(
2662            client.drain_sync_intents().as_slice(),
2663            [SyncIntent::Background { delay_ms: 250 }]
2664        ));
2665    }
2666
2667    #[test]
2668    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2669        let mut client = client();
2670        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2671        client
2672            .subscribe(
2673                "epoch-tasks".to_owned(),
2674                "tasks".to_owned(),
2675                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2676                None,
2677            )
2678            .expect("subscribe");
2679        client.drain_sync_intents();
2680
2681        client
2682            .run_log_epoch_reset("epoch-2")
2683            .expect("reset partition log epoch");
2684
2685        assert!(matches!(
2686            client.drain_sync_intents().as_slice(),
2687            [SyncIntent::Interactive]
2688        ));
2689    }
2690
2691    #[derive(Default)]
2692    struct CountingRealtimeTransport {
2693        connects: usize,
2694        closes: usize,
2695        messages: Vec<String>,
2696        blob_uploads: Vec<(String, Vec<u8>)>,
2697        blob_download: Option<Vec<u8>>,
2698        sync_calls: usize,
2699        realtime_sync_calls: usize,
2700        refuse_connect: bool,
2701    }
2702
2703    impl Transport for CountingRealtimeTransport {
2704        fn blob_upload(
2705            &mut self,
2706            blob_id: &str,
2707            bytes: &[u8],
2708            _media_type: Option<&str>,
2709        ) -> Result<(), TransportError> {
2710            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2711            Ok(())
2712        }
2713
2714        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2715            self.blob_download
2716                .clone()
2717                .map(BlobDownload::Bytes)
2718                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2719        }
2720
2721        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2722            self.sync_calls += 1;
2723            Err(TransportError::new("sync.transport_failed", "offline"))
2724        }
2725
2726        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2727            self.realtime_sync_calls += 1;
2728            Err(TransportError::new("sync.transport_failed", "offline"))
2729        }
2730
2731        fn download_segment(
2732            &mut self,
2733            _request: &SegmentRequest,
2734            _on_progress: &mut dyn FnMut(u64),
2735        ) -> Result<Vec<u8>, TransportError> {
2736            Err(TransportError::new("sync.transport_failed", "offline"))
2737        }
2738
2739        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2740            self.connects += 1;
2741            if self.refuse_connect {
2742                return Err(TransportError::new("transport.failed", "refused"));
2743            }
2744            Ok(())
2745        }
2746
2747        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2748            self.messages.push(text.to_owned());
2749            Ok(())
2750        }
2751
2752        fn realtime_close(&mut self) -> Result<(), TransportError> {
2753            self.closes += 1;
2754            Ok(())
2755        }
2756    }
2757
2758    #[test]
2759    fn blob_results_own_downloaded_bytes() {
2760        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2761            {"name":"id","type":"string","nullable":false},
2762            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2763        let bytes = b"owned typed result".to_vec();
2764        let blob_id = blob_id_for(&bytes);
2765        let mut transport = CountingRealtimeTransport {
2766            blob_download: Some(bytes.clone()),
2767            ..Default::default()
2768        };
2769        let result = {
2770            let mut client =
2771                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2772                    .unwrap();
2773            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2774            assert_eq!(result.blob_id, blob_id);
2775            assert_eq!(result.byte_length, bytes.len() as i64);
2776            assert_eq!(result.bytes, bytes);
2777            assert_eq!(result.media_type, None);
2778            assert_eq!(
2779                client
2780                    .conn
2781                    .query_row(
2782                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2783                        rusqlite::params![blob_id],
2784                        |row| row.get::<_, i64>(0),
2785                    )
2786                    .unwrap(),
2787                0
2788            );
2789            result
2790        };
2791        assert_eq!(result.bytes, bytes);
2792
2793        let mut client =
2794            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2795        assert_eq!(
2796            client
2797                .fetch_blob_bytes(&mut transport, "not json")
2798                .unwrap_err(),
2799            (
2800                "client.failed".to_owned(),
2801                "blob ref is not JSON".to_owned()
2802            )
2803        );
2804    }
2805
2806    #[test]
2807    fn visible_blob_references_survive_cache_trimming() {
2808        let schema = json!({"version":1,"tables":[
2809            {"name":"attachments","primaryKey":"id","columns":[
2810                {"name":"id","type":"string","nullable":false},
2811                {"name":"body","type":"blob_ref","nullable":true},
2812                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2813            {"name":"avatars","primaryKey":"id","columns":[
2814                {"name":"id","type":"string","nullable":false},
2815                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2816        ]});
2817        let bytes = b"referenced above cap".to_vec();
2818        let blob_id = blob_id_for(&bytes);
2819        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2820        let mut client = SyncClient::new(
2821            "visible-blob-refs".to_owned(),
2822            &schema,
2823            ClientLimits {
2824                blob_cache_max_bytes: Some(1),
2825                ..Default::default()
2826            },
2827        )
2828        .unwrap();
2829        client.conn.execute(
2830            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2831            rusqlite::params![reference, reference, reference, reference],
2832        ).unwrap();
2833        client
2834            .conn
2835            .execute(
2836                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2837                rusqlite::params![reference],
2838            )
2839            .unwrap();
2840        client.conn.execute(
2841            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2842            [],
2843        ).unwrap();
2844        let mut transport = CountingRealtimeTransport {
2845            blob_download: Some(bytes.clone()),
2846            ..Default::default()
2847        };
2848
2849        assert_eq!(
2850            client
2851                .fetch_blob_bytes(&mut transport, &blob_id)
2852                .unwrap()
2853                .bytes,
2854            bytes
2855        );
2856        let rows = client
2857            .conn
2858            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
2859            .unwrap()
2860            .query_map([], |row| row.get::<_, String>(0))
2861            .unwrap()
2862            .collect::<Result<Vec<_>, _>>()
2863            .unwrap();
2864        assert_eq!(rows, vec![blob_id]);
2865    }
2866
2867    #[test]
2868    fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
2869        let mut transport = CountingRealtimeTransport::default();
2870        let path = std::env::temp_dir().join(format!(
2871            "syncular-transport-gate-{}.db",
2872            uuid::Uuid::new_v4()
2873        ));
2874        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2875            {"name":"id","type":"string","nullable":false},
2876            {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
2877        let mut client = SyncClient::open_path(
2878            "gate".into(),
2879            &schema,
2880            ClientLimits::default(),
2881            path.to_str().unwrap(),
2882        )
2883        .unwrap();
2884        client.connect_realtime(&mut transport).unwrap();
2885        client.set_transport_enabled(&mut transport, false);
2886        assert_eq!(transport.closes, 1);
2887        client.begin_security_preflight();
2888        client.activate_security(Default::default()).unwrap();
2889        client
2890            .subscribe(
2891                "tasks".into(),
2892                "tasks".into(),
2893                vec![("project_id".into(), vec!["p1".into()])],
2894                None,
2895            )
2896            .unwrap();
2897        for id in ["one", "two"] {
2898            client
2899                .mutate(vec![Mutation::Upsert {
2900                    table: "tasks".into(),
2901                    values: Map::from_iter([
2902                        ("id".into(), json!(id)),
2903                        ("project_id".into(), json!("p1")),
2904                    ]),
2905                    base_version: None,
2906                }])
2907                .unwrap();
2908        }
2909        assert_eq!(
2910            client
2911                .query("SELECT id FROM tasks ORDER BY id", &[])
2912                .unwrap()
2913                .len(),
2914            2
2915        );
2916        assert!(
2917            matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
2918        );
2919        assert!(client
2920            .connect_realtime(&mut transport)
2921            .unwrap_err()
2922            .starts_with("sync.offline:"));
2923        assert!(client
2924            .set_presence(&mut transport, "project:p1", None)
2925            .unwrap_err()
2926            .starts_with("sync.offline:"));
2927        assert_eq!(transport.sync_calls, 0);
2928        assert_eq!(transport.realtime_sync_calls, 0);
2929        assert_eq!(transport.connects, 1);
2930        assert!(transport.messages.is_empty());
2931        assert!(matches!(
2932            client.drain_sync_intents().as_slice(),
2933            [SyncIntent::None]
2934        ));
2935        let pending = client.pending_commit_ids();
2936        drop(client);
2937        let mut reopened = SyncClient::open_path(
2938            "gate".into(),
2939            &schema,
2940            ClientLimits::default(),
2941            path.to_str().unwrap(),
2942        )
2943        .unwrap();
2944        assert!(reopened.transport_enabled());
2945        reopened.set_transport_enabled(&mut transport, false);
2946        assert_eq!(reopened.pending_commit_ids(), pending);
2947        assert_eq!(
2948            reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
2949            2
2950        );
2951        reopened.set_transport_enabled(&mut transport, true);
2952        assert!(matches!(
2953            reopened.drain_sync_intents().as_slice(),
2954            [SyncIntent::Interactive]
2955        ));
2956        reopened.set_transport_enabled(&mut transport, true);
2957        assert!(reopened.drain_sync_intents().is_empty());
2958        drop(reopened);
2959        std::fs::remove_file(path).unwrap();
2960    }
2961
2962    #[test]
2963    fn realtime_connection_ownership_is_idempotent() {
2964        let mut client = client();
2965        let mut transport = CountingRealtimeTransport {
2966            connects: 0,
2967            closes: 0,
2968            ..Default::default()
2969        };
2970        client
2971            .connect_realtime(&mut transport)
2972            .expect("first connect");
2973        client
2974            .connect_realtime(&mut transport)
2975            .expect("idempotent connect");
2976        assert_eq!(transport.connects, 1);
2977        client.disconnect_realtime(&mut transport);
2978        client.disconnect_realtime(&mut transport);
2979        assert_eq!(transport.closes, 1);
2980        client
2981            .connect_realtime(&mut transport)
2982            .expect("deliberate reconnect");
2983        assert_eq!(transport.connects, 2);
2984    }
2985
2986    #[test]
2987    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
2988        let mut client = client();
2989        client
2990            .set_realtime_policy(RealtimePolicy::Required)
2991            .expect("set policy");
2992        let mut transport = CountingRealtimeTransport::default();
2993        match client.sync(&mut transport) {
2994            SyncOutcome::RealtimeUnavailable {
2995                state,
2996                reason_code,
2997                retry_delay_ms,
2998            } => {
2999                assert_eq!(state, RealtimeState::Disconnected);
3000                assert_eq!(reason_code, None);
3001                assert_eq!(retry_delay_ms, 250);
3002            }
3003            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3004        }
3005        assert_eq!(transport.sync_calls, 0);
3006        assert!(matches!(
3007            client.drain_sync_intents().as_slice(),
3008            [SyncIntent::Background { delay_ms: 250 }]
3009        ));
3010        let snapshot = client
3011            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3012            .expect("diagnostics");
3013        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3014        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
3015        assert_eq!(snapshot.host.realtime_reason_code, None);
3016        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3017        assert_eq!(
3018            snapshot
3019                .last_round
3020                .as_ref()
3021                .and_then(|round| round.error_code.clone()),
3022            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
3023        );
3024    }
3025
3026    #[test]
3027    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
3028        let mut client = client();
3029        client
3030            .set_realtime_policy(RealtimePolicy::Required)
3031            .expect("set policy");
3032        let mut transport = CountingRealtimeTransport {
3033            refuse_connect: true,
3034            ..Default::default()
3035        };
3036        assert!(client.connect_realtime(&mut transport).is_err());
3037        assert_eq!(client.realtime_state(), RealtimeState::Refused);
3038        let snapshot = client
3039            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3040            .expect("diagnostics");
3041        assert_eq!(
3042            snapshot.host.realtime_reason_code.as_deref(),
3043            Some("transport.failed")
3044        );
3045        match client.sync(&mut transport) {
3046            SyncOutcome::RealtimeUnavailable {
3047                state, reason_code, ..
3048            } => {
3049                assert_eq!(state, RealtimeState::Refused);
3050                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
3051            }
3052            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3053        }
3054        assert_eq!(transport.sync_calls, 0);
3055        assert_eq!(transport.realtime_sync_calls, 0);
3056    }
3057
3058    #[test]
3059    fn an_unsolicited_socket_end_is_lost_with_the_transport_code_and_a_retry_intent() {
3060        let mut client = client();
3061        let mut transport = CountingRealtimeTransport::default();
3062        let ended = TransportError::new("transport.failed", "realtime connection closed");
3063        client.realtime_lost(&mut transport, &ended);
3064        assert_eq!(
3065            client.realtime_state(),
3066            RealtimeState::Disconnected,
3067            "a socket that was never connected has nothing to lose"
3068        );
3069        assert!(client.drain_sync_intents().is_empty());
3070
3071        client.connect_realtime(&mut transport).expect("connect");
3072        client.realtime_lost(&mut transport, &ended);
3073        assert_eq!(client.realtime_state(), RealtimeState::Lost);
3074        assert_eq!(transport.closes, 1);
3075        assert!(matches!(
3076            client.drain_sync_intents().as_slice(),
3077            [SyncIntent::Background { delay_ms: 250 }]
3078        ));
3079        let snapshot = client
3080            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3081            .expect("diagnostics");
3082        assert_eq!(snapshot.host.realtime, RealtimeState::Lost);
3083        assert_eq!(
3084            snapshot.host.realtime_reason_code.as_deref(),
3085            Some("transport.failed")
3086        );
3087        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3088
3089        client.realtime_lost(&mut transport, &ended);
3090        assert!(
3091            client.drain_sync_intents().is_empty(),
3092            "a second report for the same loss schedules nothing"
3093        );
3094        client.connect_realtime(&mut transport).expect("reconnect");
3095        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3096    }
3097
3098    #[test]
3099    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
3100        let mut client = client();
3101        client
3102            .set_realtime_policy(RealtimePolicy::Required)
3103            .expect("set policy");
3104        let mut transport = CountingRealtimeTransport::default();
3105        client.connect_realtime(&mut transport).expect("connect");
3106        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3107        assert!(
3108            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
3109            "a policy change cannot silently re-route a connected socket"
3110        );
3111        assert!(matches!(
3112            client.sync(&mut transport),
3113            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
3114        ));
3115        assert_eq!(client.realtime_state(), RealtimeState::Lost);
3116        assert_eq!(transport.realtime_sync_calls, 1);
3117        assert_eq!(transport.sync_calls, 0);
3118        let snapshot = client
3119            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3120            .expect("diagnostics");
3121        assert_eq!(
3122            snapshot.host.realtime_reason_code.as_deref(),
3123            Some("sync.transport_failed")
3124        );
3125        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3126        match client.sync(&mut transport) {
3127            SyncOutcome::RealtimeUnavailable {
3128                state, reason_code, ..
3129            } => {
3130                assert_eq!(state, RealtimeState::Lost);
3131                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
3132            }
3133            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3134        }
3135        assert_eq!(transport.sync_calls, 0);
3136        client.disconnect_realtime(&mut transport);
3137        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
3138        assert_eq!(transport.closes, 1);
3139        client.connect_realtime(&mut transport).expect("reconnect");
3140        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3141        assert_eq!(transport.connects, 2);
3142    }
3143
3144    #[test]
3145    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
3146        let mut client = client();
3147        client
3148            .set_realtime_policy(RealtimePolicy::Off)
3149            .expect("set policy");
3150        let mut transport = CountingRealtimeTransport::default();
3151        assert!(client.connect_realtime(&mut transport).is_err());
3152        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
3153        assert!(matches!(
3154            client.sync(&mut transport),
3155            SyncOutcome::Failed { .. }
3156        ));
3157        assert_eq!(transport.sync_calls, 1);
3158        assert_eq!(transport.realtime_sync_calls, 0);
3159        let snapshot = client
3160            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3161            .expect("diagnostics");
3162        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
3163        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
3164    }
3165
3166    #[test]
3167    fn optional_policy_keeps_the_http_round() {
3168        let mut client = client();
3169        let mut transport = CountingRealtimeTransport::default();
3170        assert!(matches!(
3171            client.sync(&mut transport),
3172            SyncOutcome::Failed { .. }
3173        ));
3174        assert_eq!(transport.sync_calls, 1);
3175        assert_eq!(transport.realtime_sync_calls, 0);
3176        let snapshot = client
3177            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3178            .expect("diagnostics");
3179        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3180        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
3181    }
3182
3183    #[test]
3184    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
3185        let mut client = client();
3186        client
3187            .subscribe(
3188                "repair-tasks".to_owned(),
3189                "tasks".to_owned(),
3190                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3191                None,
3192            )
3193            .expect("subscribe");
3194        {
3195            let sub = client
3196                .subs
3197                .iter_mut()
3198                .find(|sub| sub.id == "repair-tasks")
3199                .expect("subscription");
3200            sub.cursor = 42;
3201            sub.synced_once = true;
3202            let persisted = sub.clone();
3203            client.persist_sub(&persisted).unwrap();
3204        }
3205        for table in ["_syncular_base_tasks", "tasks"] {
3206            client
3207                .conn
3208                .execute(
3209                    &format!(
3210                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
3211                    ),
3212                    rusqlite::params!["server-row", "p1"],
3213                )
3214                .expect("seed server row");
3215        }
3216        let pending = client
3217            .mutate(vec![Mutation::Upsert {
3218                table: "tasks".to_owned(),
3219                values: Map::from_iter([
3220                    ("id".to_owned(), Value::from("offline-row")),
3221                    ("project_id".to_owned(), Value::from("p1")),
3222                ]),
3223                base_version: None,
3224            }])
3225            .expect("queue offline work");
3226        client.drain_change_batches();
3227        client.drain_sync_intents();
3228
3229        assert_eq!(
3230            client
3231                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3232                    rebootstrap_id: "support-case-001".to_owned(),
3233                })
3234                .expect("rebootstrap"),
3235            LocalDataRebootstrapResult {
3236                already_applied: false,
3237                retained_commits: 1,
3238                reset_subscriptions: 1,
3239            }
3240        );
3241        let visible_ids = client
3242            .conn
3243            .prepare("SELECT id FROM tasks ORDER BY id")
3244            .expect("prepare visible ids")
3245            .query_map([], |row| row.get::<_, String>(0))
3246            .expect("query visible ids")
3247            .collect::<Result<Vec<_>, _>>()
3248            .expect("collect visible ids");
3249        assert_eq!(visible_ids, vec!["offline-row"]);
3250        assert_eq!(client.pending_commit_ids(), vec![pending]);
3251        assert_eq!(
3252            client
3253                .subscription_state("repair-tasks")
3254                .expect("subscription")
3255                .cursor,
3256            -1
3257        );
3258        assert!(client.upgrading());
3259        assert!(client.sync_needed());
3260        assert!(matches!(
3261            client.drain_sync_intents().as_slice(),
3262            [SyncIntent::Interactive]
3263        ));
3264        assert_eq!(client.drain_change_batches().len(), 1);
3265
3266        assert_eq!(
3267            client
3268                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3269                    rebootstrap_id: "support-case-001".to_owned(),
3270                })
3271                .expect("idempotent retry"),
3272            LocalDataRebootstrapResult {
3273                already_applied: true,
3274                retained_commits: 1,
3275                reset_subscriptions: 1,
3276            }
3277        );
3278        assert!(client.drain_change_batches().is_empty());
3279    }
3280
3281    #[test]
3282    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
3283        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
3284        assert_eq!(
3285            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
3286            (3, 4)
3287        );
3288        assert_eq!(
3289            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
3290            (0, 0)
3291        );
3292        for malformed in [
3293            "",
3294            "{}",
3295            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
3296            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
3297            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
3298        ] {
3299            assert_eq!(
3300                decode_local_rebootstrap_receipt(malformed)
3301                    .expect_err("malformed receipt must fail"),
3302                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3303            );
3304        }
3305    }
3306
3307    #[test]
3308    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
3309        let path = std::env::temp_dir().join(format!(
3310            "syncular-rebootstrap-receipt-{}.db",
3311            uuid::Uuid::new_v4()
3312        ));
3313        let schema = json!({
3314            "version": 1,
3315            "tables": [{
3316                "name": "tasks",
3317                "primaryKey": "id",
3318                "columns": [
3319                    { "name": "id", "type": "string", "nullable": false },
3320                    { "name": "project_id", "type": "string", "nullable": false }
3321                ],
3322                "scopes": [{ "pattern": "project:{project_id}" }]
3323            }]
3324        });
3325        let path_string = path.to_str().expect("UTF-8 temp path");
3326
3327        {
3328            let mut first = SyncClient::open_path(
3329                "repair-restart-client".to_owned(),
3330                &schema,
3331                ClientLimits::default(),
3332                path_string,
3333            )
3334            .expect("first open");
3335            first
3336                .subscribe(
3337                    "repair-tasks".to_owned(),
3338                    "tasks".to_owned(),
3339                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3340                    None,
3341                )
3342                .expect("subscribe");
3343            first
3344                .mutate(vec![Mutation::Upsert {
3345                    table: "tasks".to_owned(),
3346                    values: Map::from_iter([
3347                        ("id".to_owned(), Value::from("offline-row")),
3348                        ("project_id".to_owned(), Value::from("p1")),
3349                    ]),
3350                    base_version: None,
3351                }])
3352                .expect("queue offline work");
3353            assert_eq!(
3354                first
3355                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
3356                        rebootstrap_id: "restart-receipt".to_owned(),
3357                    })
3358                    .expect("first rebootstrap"),
3359                LocalDataRebootstrapResult {
3360                    already_applied: false,
3361                    retained_commits: 1,
3362                    reset_subscriptions: 1,
3363                }
3364            );
3365        }
3366
3367        let mut reopened = SyncClient::open_path(
3368            "repair-restart-client".to_owned(),
3369            &schema,
3370            ClientLimits::default(),
3371            path_string,
3372        )
3373        .expect("reopen");
3374        reopened.drain_change_batches();
3375        reopened.drain_sync_intents();
3376        assert_eq!(
3377            reopened
3378                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3379                    rebootstrap_id: "restart-receipt".to_owned(),
3380                })
3381                .expect("receipt replay"),
3382            LocalDataRebootstrapResult {
3383                already_applied: true,
3384                retained_commits: 1,
3385                reset_subscriptions: 1,
3386            }
3387        );
3388        assert!(reopened.drain_change_batches().is_empty());
3389        assert!(reopened.drain_sync_intents().is_empty());
3390        drop(reopened);
3391        std::fs::remove_file(path).expect("remove temp database");
3392    }
3393
3394    #[test]
3395    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
3396        let mut malformed = client();
3397        malformed
3398            .subscribe(
3399                "repair-tasks".to_owned(),
3400                "tasks".to_owned(),
3401                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3402                None,
3403            )
3404            .expect("subscribe");
3405        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
3406        malformed.drain_change_batches();
3407        malformed.drain_sync_intents();
3408        let malformed_error = malformed
3409            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3410                rebootstrap_id: "malformed".to_owned(),
3411            })
3412            .expect_err("malformed receipt must fail closed");
3413        assert_eq!(
3414            malformed_error,
3415            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3416        );
3417        assert!(!malformed.upgrading());
3418        assert_eq!(
3419            malformed
3420                .subscription_state("repair-tasks")
3421                .expect("unchanged subscription")
3422                .cursor,
3423            -1
3424        );
3425        assert!(malformed.drain_change_batches().is_empty());
3426        assert!(malformed.drain_sync_intents().is_empty());
3427
3428        let mut unreadable = client();
3429        unreadable
3430            .conn
3431            .execute(
3432                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
3433                rusqlite::params!["server-row", "p1"],
3434            )
3435            .expect("seed visible row");
3436        unreadable
3437            .conn
3438            .execute("DROP TABLE _syncular_meta", [])
3439            .expect("break marker storage");
3440        let unreadable_error = unreadable
3441            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3442                rebootstrap_id: "unreadable".to_owned(),
3443            })
3444            .expect_err("unreadable marker storage must fail closed");
3445        assert_eq!(
3446            unreadable_error,
3447            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
3448        );
3449        let visible_rows = unreadable
3450            .conn
3451            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
3452            .expect("visible projection remains");
3453        assert_eq!(visible_rows, 1);
3454        assert!(!unreadable.upgrading());
3455        assert!(unreadable.drain_change_batches().is_empty());
3456        assert!(unreadable.drain_sync_intents().is_empty());
3457    }
3458
3459    #[test]
3460    fn local_rebootstrap_cannot_bypass_schema_floor() {
3461        let mut client = client();
3462        client.set_schema_floor(Some(SchemaFloor {
3463            required_schema_version: Some(2),
3464            latest_schema_version: Some(2),
3465        }));
3466        let error = client
3467            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3468                rebootstrap_id: "blocked-floor".to_owned(),
3469            })
3470            .expect_err("schema floor must block repair");
3471        assert!(error.contains("cannot bypass an active schema-floor stop"));
3472    }
3473
3474    #[test]
3475    fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
3476        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3477            "columns":[{"name":"id","type":"string","nullable":false},
3478                {"name":"project_id","type":"string","nullable":false},
3479                {"name":"starts_at_ms","type":"integer","nullable":false},
3480                {"name":"version","type":"integer","nullable":false}],
3481            "scopes":[{"pattern":"project:{project_id}"}]}]});
3482        let mut client =
3483            SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
3484        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3485        client
3486            .write_base_row(
3487                "tasks",
3488                &vec![
3489                    Some(ColumnValue::String("t1".into())),
3490                    Some(ColumnValue::String("p1".into())),
3491                    Some(ColumnValue::Integer(100)),
3492                    Some(ColumnValue::Integer(1)),
3493                ],
3494                1,
3495            )
3496            .unwrap();
3497        client.rebuild_overlay().expect("rebuild overlay");
3498        let first = client
3499            .patch(
3500                "tasks",
3501                "t1",
3502                Map::from_iter([
3503                    ("starts_at_ms".into(), json!(200)),
3504                    ("version".into(), json!(2)),
3505                ]),
3506                None,
3507            )
3508            .unwrap();
3509        let (_, first_request) = client.build_request(false).unwrap();
3510        // This second edit happens while the first request is in flight.
3511        let second = client
3512            .patch(
3513                "tasks",
3514                "t1",
3515                Map::from_iter([
3516                    ("starts_at_ms".into(), json!(300)),
3517                    ("version".into(), json!(3)),
3518                ]),
3519                None,
3520            )
3521            .unwrap();
3522        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3523        for (id, seq, meta) in [
3524            (first, 2, first_request),
3525            (second, 3, client.build_request(false).unwrap().1),
3526        ] {
3527            let response = Message {
3528                wire_version: WIRE_VERSION,
3529                msg_kind: MsgKind::Response,
3530                frames: vec![
3531                    Frame::RespHeader {
3532                        required_schema_version: None,
3533                        latest_schema_version: None,
3534                        log_epoch: Some("epoch-1".into()),
3535                        reset_required: Some(false),
3536                    },
3537                    Frame::PushResult {
3538                        client_commit_id: id,
3539                        status: PushStatus::Applied,
3540                        commit_seq: Some(seq),
3541                        results: vec![OpResult::Applied { op_index: 0 }],
3542                    },
3543                ],
3544            };
3545            assert!(matches!(
3546                client.process_response(&mut transport, response, &meta),
3547                SyncOutcome::Ok(_)
3548            ));
3549            let row = &client
3550                .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
3551                .unwrap()[0];
3552            assert_eq!(
3553                row["starts_at_ms"],
3554                json!(300),
3555                "an applied ACK without a pull image must not expose the old base"
3556            );
3557            assert_eq!(
3558                row["version"],
3559                json!(3),
3560                "the queued domain version must remain monotonic"
3561            );
3562        }
3563    }
3564
3565    #[test]
3566    fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
3567        for stored in [None, Some("epoch-old")] {
3568            let mut client = client();
3569            client
3570                .subscribe(
3571                    "tasks".into(),
3572                    "tasks".into(),
3573                    vec![("project_id".into(), vec!["p1".into()])],
3574                    None,
3575                )
3576                .unwrap();
3577            client
3578                .conn
3579                .execute_batch(
3580                    "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
3581                )
3582                .unwrap();
3583            let id = client
3584                .mutate(vec![Mutation::Upsert {
3585                    table: "tasks".into(),
3586                    values: Map::from_iter([
3587                        ("id".into(), json!("offline")),
3588                        ("project_id".into(), json!("p1")),
3589                    ]),
3590                    base_version: None,
3591                }])
3592                .unwrap();
3593            if let Some(epoch) = stored {
3594                client.set_meta(LOG_EPOCH_KEY, epoch);
3595            }
3596            let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
3597            let (_, meta) = client.build_request(false).unwrap();
3598            client.drain_change_batches();
3599            client.drain_sync_intents();
3600            let response = Message {
3601                wire_version: WIRE_VERSION,
3602                msg_kind: MsgKind::Response,
3603                frames: vec![Frame::RespHeader {
3604                    required_schema_version: None,
3605                    latest_schema_version: None,
3606                    log_epoch: Some("epoch-new".into()),
3607                    reset_required: Some(true),
3608                }],
3609            };
3610            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3611            let outcome = client.process_response(&mut transport, response, &meta);
3612            let SyncOutcome::Ok(report) = outcome else {
3613                panic!("epoch round failed: {outcome:?}");
3614            };
3615            assert_eq!(
3616                report.resets,
3617                if stored.is_none() {
3618                    Vec::<String>::new()
3619                } else {
3620                    vec!["tasks".into()]
3621                }
3622            );
3623            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3624            assert_eq!(client.pending_commit_ids(), vec![id]);
3625            assert_eq!(
3626                client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3627                json!("offline")
3628            );
3629            let trigger: i64 = client
3630                .conn
3631                .query_row(
3632                    "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3633                    [],
3634                    |row| row.get(0),
3635                )
3636                .unwrap();
3637            if stored.is_none() {
3638                assert!(!client.upgrading);
3639                assert!(client
3640                    .drain_change_batches()
3641                    .iter()
3642                    .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3643                assert_eq!(
3644                    serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3645                    before
3646                );
3647                assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3648            } else {
3649                assert!(client.upgrading);
3650                assert_eq!(trigger, 0, "an epoch change must drop the old table");
3651            }
3652            assert!(matches!(
3653                client.drain_sync_intents().as_slice(),
3654                [SyncIntent::Interactive]
3655            ));
3656        }
3657    }
3658
3659    #[test]
3660    fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3661        for key in ["logEpoch", "localRevision"] {
3662            let mut client = client();
3663            let id = client
3664                .mutate(vec![Mutation::Upsert {
3665                    table: "tasks".into(),
3666                    values: Map::from_iter([
3667                        ("id".into(), json!("offline")),
3668                        ("project_id".into(), json!("p1")),
3669                    ]),
3670                    base_version: None,
3671                }])
3672                .unwrap();
3673            let revision = client.local_revision();
3674            let needed = client.sync_needed;
3675            client.drain_change_batches();
3676            client.drain_sync_intents();
3677            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();
3678            assert!(client.run_log_epoch_reset("epoch-new").is_err());
3679            assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3680            assert!(!client.upgrading);
3681            assert_eq!(client.sync_needed, needed);
3682            assert_eq!(client.local_revision(), revision);
3683            assert_eq!(client.pending_commit_ids(), [id]);
3684            assert!(client.drain_change_batches().is_empty());
3685            assert!(client.drain_sync_intents().is_empty());
3686        }
3687    }
3688
3689    #[test]
3690    fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3691        for key in [
3692            "localSchemaVersion",
3693            "localSchemaDescriptor",
3694            "logEpoch",
3695            "localRevision",
3696        ] {
3697            let mut client = client();
3698            client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3699            let commit = client
3700                .mutate(vec![Mutation::Upsert {
3701                    table: "tasks".into(),
3702                    values: Map::from_iter([
3703                        ("id".into(), json!("offline")),
3704                        ("project_id".into(), json!("p1")),
3705                    ]),
3706                    base_version: None,
3707                }])
3708                .unwrap();
3709            client.active_round = Some(uuid::Uuid::new_v4());
3710            client.overlay_dirty.set(false);
3711            let round = client.active_round;
3712            let needed = client.sync_needed;
3713            let revision = client.local_revision();
3714            let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3715            client.drain_change_batches();
3716            client.drain_sync_intents();
3717            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();
3718            assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3719            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3720            assert!(!client.upgrading);
3721            assert_eq!(client.active_round, round);
3722            assert!(!client.overlay_dirty.get());
3723            assert_eq!(client.sync_needed, needed);
3724            assert_eq!(client.local_revision(), revision);
3725            assert_eq!(
3726                client.pending_commit_ids().as_slice(),
3727                std::slice::from_ref(&commit)
3728            );
3729            assert_eq!(
3730                serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3731                rows
3732            );
3733            assert!(client.drain_change_batches().is_empty());
3734            assert!(client.drain_sync_intents().is_empty());
3735            assert!(client.conn.is_autocommit());
3736            client
3737                .conn
3738                .execute_batch("DROP TRIGGER fail_reset")
3739                .unwrap();
3740            client.run_log_epoch_reset("epoch-new").unwrap();
3741            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3742            assert_eq!(client.pending_commit_ids(), [commit]);
3743        }
3744    }
3745
3746    #[test]
3747    fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3748        for subscriptions in [0, 2, 6, 12] {
3749            let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3750                { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3751                { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3752            ] }), ClientLimits::default()).unwrap();
3753            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3754            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();
3755            for index in 0..32 {
3756                client
3757                    .mutate(vec![Mutation::Upsert {
3758                        table: "tasks".into(),
3759                        values: Map::from_iter([
3760                            ("id".into(), json!(format!("task-{index}"))),
3761                            ("project_id".into(), json!("p1")),
3762                        ]),
3763                        base_version: None,
3764                    }])
3765                    .unwrap();
3766            }
3767            let (_, meta) = client.build_request(false).unwrap();
3768            let mut frames = vec![Frame::RespHeader {
3769                required_schema_version: None,
3770                latest_schema_version: None,
3771                log_epoch: Some("epoch-1".into()),
3772                reset_required: Some(false),
3773            }];
3774            frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3775                Frame::PushResult {
3776                    client_commit_id: id.clone(),
3777                    status: PushStatus::Applied,
3778                    commit_seq: Some(index as i64 + 1),
3779                    results: vec![OpResult::Applied { op_index: 0 }],
3780                }
3781            }));
3782            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3783            client.acknowledged_replay_count.set(0);
3784            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3785            assert!(matches!(
3786                client.process_response(
3787                    &mut transport,
3788                    Message {
3789                        wire_version: WIRE_VERSION,
3790                        msg_kind: MsgKind::Response,
3791                        frames
3792                    },
3793                    &meta
3794                ),
3795                SyncOutcome::Ok(_)
3796            ));
3797            assert_eq!(client.acknowledged_replay_count.get(), 32);
3798            assert_eq!(
3799                client
3800                    .query(
3801                        "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3802                        &[]
3803                    )
3804                    .unwrap()[0]["n"],
3805                json!(32)
3806            );
3807            assert_eq!(
3808                client
3809                    .query(
3810                        "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3811                        &[]
3812                    )
3813                    .unwrap()[0]["n"],
3814                json!(32)
3815            );
3816            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3817            client.acknowledged_replay_count.set(0);
3818            for index in 0..subscriptions {
3819                client
3820                    .subscribe(
3821                        format!("docs-{index}"),
3822                        "docs".into(),
3823                        vec![("project_id".into(), vec![format!("scope-{index}")])],
3824                        None,
3825                    )
3826                    .unwrap();
3827                let sub = client.subs.len() - 1;
3828                client.subs[sub].effective =
3829                    Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3830                let segment = RowsSegment {
3831                    table: "docs".into(),
3832                    schema_version: 1,
3833                    columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3834                    blocks: if index % 2 == 0 {
3835                        vec![vec![ssp2::segment::SegmentRow {
3836                            values: vec![
3837                                Some(ColumnValue::String(format!("doc-{index}"))),
3838                                Some(ColumnValue::String(format!("scope-{index}"))),
3839                            ],
3840                            server_version: 1,
3841                        }]]
3842                    } else {
3843                        vec![]
3844                    },
3845                };
3846                assert!(client.apply_segment(sub, &segment, true).is_ok());
3847            }
3848            assert_eq!(
3849                client
3850                    .query("SELECT count(*) AS n FROM ack_writes", &[])
3851                    .unwrap()[0]["n"],
3852                json!(0),
3853                "unrelated bootstrap must not restore/replay tasks"
3854            );
3855            assert_eq!(
3856                client.acknowledged_replay_count.get(),
3857                0,
3858                "unrelated bootstrap must not decode task ACKs"
3859            );
3860            assert_eq!(
3861                client
3862                    .query("SELECT count(*) AS n FROM tasks", &[])
3863                    .unwrap()[0]["n"],
3864                json!(32)
3865            );
3866            assert_eq!(
3867                client
3868                    .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
3869                    .unwrap()[0]["n"],
3870                json!(32)
3871            );
3872        }
3873    }
3874
3875    #[test]
3876    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
3877        let mut client = client();
3878        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3879        const COMMIT_COUNT: usize = 32;
3880
3881        for index in 0..COMMIT_COUNT {
3882            client
3883                .mutate(vec![Mutation::Upsert {
3884                    table: "tasks".to_owned(),
3885                    values: Map::from_iter([
3886                        ("id".to_owned(), Value::from(format!("task-{index}"))),
3887                        ("project_id".to_owned(), Value::from("project-1")),
3888                    ]),
3889                    base_version: None,
3890                }])
3891                .expect("queue commit");
3892        }
3893
3894        let (_, request_meta) = client.build_request(false).unwrap();
3895        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
3896        let mut frames = vec![Frame::RespHeader {
3897            required_schema_version: None,
3898            latest_schema_version: None,
3899            log_epoch: Some("epoch-1".to_owned()),
3900            reset_required: Some(false),
3901        }];
3902        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
3903            |(index, client_commit_id)| Frame::PushResult {
3904                client_commit_id: client_commit_id.clone(),
3905                status: PushStatus::Applied,
3906                commit_seq: Some(index as i64 + 1),
3907                results: vec![OpResult::Applied { op_index: 0 }],
3908            },
3909        ));
3910        let response = Message {
3911            wire_version: WIRE_VERSION,
3912            msg_kind: MsgKind::Response,
3913            frames,
3914        };
3915        let mut transport =
3916            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3917
3918        client.overlay_rebuild_count.set(0);
3919        client.outcome_prune_count.set(0);
3920        let outcome = client.process_response(&mut transport, response, &request_meta);
3921        assert!(matches!(outcome, SyncOutcome::Ok(_)));
3922        assert!(client.pending_commit_ids().is_empty());
3923        assert_eq!(
3924            client.overlay_rebuild_count.get(),
3925            1,
3926            "one response must reconcile its acknowledged commits with one overlay rebuild"
3927        );
3928        assert_eq!(
3929            client.outcome_prune_count.get(),
3930            1,
3931            "one response must enforce outcome retention once"
3932        );
3933    }
3934
3935    #[test]
3936    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
3937        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
3938            "columns": [
3939                {"name":"id", "type":"string", "nullable":false},
3940                {"name":"project_id", "type":"string", "nullable":false},
3941                {"name":"title", "type":"string", "nullable":false}
3942            ], "scopes": [{"pattern":"project:{project_id}"}],
3943            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3944            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
3945        }] });
3946        let mut incremental =
3947            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3948        let mut reference =
3949            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3950        let tables = vec!["tasks".to_owned()];
3951        for index in 0..32 {
3952            let id = format!("task-{}", index % 8);
3953            let values = Map::from_iter([
3954                ("id".to_owned(), Value::from(id.clone())),
3955                (
3956                    "project_id".to_owned(),
3957                    Value::from(format!("p{}", index % 2)),
3958                ),
3959                ("title".to_owned(), Value::from(format!("needle{index}"))),
3960            ]);
3961            let payload = encode_row_json(
3962                incremental.schema.table("tasks").unwrap(),
3963                &id,
3964                &values,
3965                &incremental.encryption,
3966            )
3967            .unwrap();
3968            let change = ssp2::model::Change {
3969                table_index: 0,
3970                row_id: id,
3971                op: if index % 5 == 0 {
3972                    Op::Delete
3973                } else {
3974                    Op::Upsert
3975                },
3976                row_version: Some(index + 1),
3977                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
3978                row: Some(payload),
3979            };
3980            reference.overlay_dirty.set(true);
3981            for instance in [&mut incremental, &mut reference] {
3982                instance.begin_observation("mirror_test").unwrap();
3983                let mut batch = ChangeAccumulator::default();
3984                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
3985                instance
3986                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
3987                    .unwrap();
3988                instance
3989                    .rebuild_overlay_if_dirty()
3990                    .expect("rebuild overlay");
3991                instance.finish_observation("mirror_test", batch).unwrap();
3992            }
3993            for sql in [
3994                "SELECT * FROM tasks ORDER BY id",
3995                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3996            ] {
3997                assert_eq!(
3998                    incremental.query(sql, &[]).unwrap(),
3999                    reference.query(sql, &[]).unwrap()
4000                );
4001            }
4002            assert_eq!(incremental.local_revision(), reference.local_revision());
4003            assert_eq!(
4004                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4005                serde_json::to_value(reference.drain_change_batches()).unwrap()
4006            );
4007        }
4008        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4009        assert_eq!(reference.overlay_rebuild_count.get(), 32);
4010    }
4011
4012    #[test]
4013    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
4014        let cases = [
4015            (
4016                "string",
4017                vec![
4018                    json!("1"),
4019                    json!("01"),
4020                    json!(""),
4021                    json!("nul\u{0}id"),
4022                    json!("é"),
4023                ],
4024            ),
4025            (
4026                "integer",
4027                vec![
4028                    json!(i64::MIN),
4029                    json!(-1),
4030                    json!(0),
4031                    json!(1),
4032                    json!(i64::MAX),
4033                ],
4034            ),
4035            ("boolean", vec![json!(false), json!(true)]),
4036            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
4037        ];
4038        for (kind, ids) in cases {
4039            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4040                "columns":[{"name":"id","type":kind,"nullable":false},
4041                    {"name":"project_id","type":"string","nullable":false}],
4042                "scopes":[{"pattern":"project:{project_id}"}]}]});
4043            let mut client =
4044                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
4045            for (index, id) in ids.iter().enumerate() {
4046                let table = client.schema.table("tasks").unwrap();
4047                client
4048                    .write_base_row(
4049                        "tasks",
4050                        &vec![
4051                            if kind == "integer" {
4052                                // Exercise local SQLite key lookup across its
4053                                // full i64 range independently of authoring.
4054                                Some(ColumnValue::Integer(id.as_i64().unwrap()))
4055                            } else {
4056                                json_to_column_value(&table.columns[0], Some(id)).unwrap()
4057                            },
4058                            Some(ColumnValue::String(format!("p{index}"))),
4059                        ],
4060                        1,
4061                    )
4062                    .unwrap();
4063            }
4064            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4065            for base in [true, false] {
4066                let full = if base {
4067                    base_table("tasks")
4068                } else {
4069                    visible_table("tasks")
4070                };
4071                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
4072                for row_id in [
4073                    "1",
4074                    "01",
4075                    "1.0",
4076                    "1.0000000000000002",
4077                    "+1",
4078                    "1e0",
4079                    " 1",
4080                    "-1",
4081                    "0",
4082                    "9223372036854775807",
4083                    "-9223372036854775808",
4084                    "true",
4085                    "false",
4086                    "",
4087                    "nul\u{0}id",
4088                    "é",
4089                    "null",
4090                    "{\"a\":1}",
4091                ] {
4092                    let old_sql =
4093                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
4094                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
4095                    let old = client
4096                        .conn
4097                        .prepare(&old_sql)
4098                        .unwrap()
4099                        .query_map([row_id], |row| row.get::<_, String>(0))
4100                        .unwrap()
4101                        .collect::<Result<Vec<_>, _>>()
4102                        .unwrap();
4103                    let new = client
4104                        .conn
4105                        .prepare_cached(&new_sql)
4106                        .unwrap()
4107                        .query_map([row_id], |row| row.get::<_, String>(0))
4108                        .unwrap()
4109                        .collect::<Result<Vec<_>, _>>()
4110                        .unwrap();
4111                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
4112                    let mut batch = ChangeAccumulator::default();
4113                    assert_eq!(
4114                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
4115                        !old.is_empty()
4116                    );
4117                    let expected = old.first().map(|scope| {
4118                        BTreeMap::from([(
4119                            "tasks".to_owned(),
4120                            Some(BTreeSet::from([format!("project:{scope}")])),
4121                        )])
4122                    });
4123                    assert_eq!(batch.tables, expected.unwrap_or_default());
4124                    client
4125                        .conn
4126                        .execute_batch("SAVEPOINT lookup_delete")
4127                        .unwrap();
4128                    if base {
4129                        client.delete_base_row("tasks", row_id).unwrap();
4130                    } else {
4131                        client
4132                            .apply_outbox_ops(&[OutboxOp {
4133                                upsert: false,
4134                                table: "tasks".into(),
4135                                row_id: row_id.into(),
4136                                base_version: None,
4137                                values: None,
4138                            }])
4139                            .expect("replay delete");
4140                    }
4141                    let remaining: i64 = client
4142                        .conn
4143                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
4144                            row.get(0)
4145                        })
4146                        .unwrap();
4147                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
4148                    client
4149                        .conn
4150                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
4151                        .unwrap();
4152                    client.overlay_dirty.set(false);
4153                }
4154                for (predicate, expected) in [
4155                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
4156                    (predicate, "SEARCH"),
4157                ] {
4158                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
4159                    let details = client
4160                        .conn
4161                        .prepare(&sql)
4162                        .unwrap()
4163                        .query_map(["1"], |row| row.get::<_, String>(3))
4164                        .unwrap()
4165                        .collect::<Result<Vec<_>, _>>()
4166                        .unwrap();
4167                    assert!(
4168                        details.iter().any(|detail| detail.contains(expected)),
4169                        "{kind}: {details:?}"
4170                    );
4171                }
4172            }
4173        }
4174    }
4175
4176    #[test]
4177    fn primary_keys_reject_every_ineligible_column_type() {
4178        // §2.4: a `rowId` is a string, so a primary key type whose string form
4179        // differs per renderer, or cannot be reproduced by local storage
4180        // comparison, is a schema error rather than a silent lookup miss.
4181        for ty in ["float", "bytes", "crdt", "blob_ref"] {
4182            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4183                "columns":[{"name":"id","type":ty,"nullable":false},
4184                    {"name":"project_id","type":"string","nullable":false}],
4185                "scopes":[{"pattern":"project:{project_id}"}]}]});
4186            let error =
4187                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
4188                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
4189                    Err(error) => error,
4190                };
4191            assert!(
4192                error.contains("has an ineligible column type (§2.4)"),
4193                "{ty}: {error}"
4194            );
4195        }
4196        for ty in ["string", "integer", "boolean", "json"] {
4197            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4198                "columns":[{"name":"id","type":ty,"nullable":false},
4199                    {"name":"project_id","type":"string","nullable":false}],
4200                "scopes":[{"pattern":"project:{project_id}"}]}]});
4201            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
4202                .unwrap_or_else(|error| panic!("{ty}: {error}"));
4203        }
4204    }
4205
4206    #[test]
4207    fn pending_row_reconciliation_matches_full_fifo_replay() {
4208        for unique in [false, true] {
4209            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
4210                "name": name, "primaryKey": "id", "columns": [
4211                    {"name":"id", "type":"string", "nullable":false},
4212                    {"name":"project_id", "type":"string", "nullable":false},
4213                    {"name":"title", "type":"string", "nullable":false}],
4214                "scopes": [{"pattern":"project:{project_id}"}],
4215                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
4216                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
4217            })))});
4218            let mut incremental =
4219                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4220            let mut reference =
4221                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4222            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
4223            for instance in [&mut incremental, &mut reference] {
4224                for table in &tables {
4225                    for index in 0..12 {
4226                        instance
4227                            .write_base_row(
4228                                table,
4229                                &vec![
4230                                    Some(ColumnValue::String(format!("row{index}"))),
4231                                    Some(ColumnValue::String("p0".into())),
4232                                    Some(ColumnValue::String(format!("base{index}"))),
4233                                ],
4234                                1,
4235                            )
4236                            .unwrap();
4237                    }
4238                }
4239                instance
4240                    .rebuild_overlay_if_dirty()
4241                    .expect("rebuild overlay");
4242            }
4243            for index in 0..80 {
4244                let mutations = (0..3)
4245                    .map(|offset| {
4246                        let table = tables[(index + offset) % 2].clone();
4247                        let id = format!("row{}", (index + offset) % 16);
4248                        if (index + offset) % 7 == 0 {
4249                            Mutation::Delete {
4250                                table,
4251                                row_id: id,
4252                                base_version: None,
4253                            }
4254                        } else {
4255                            Mutation::Upsert {
4256                                table,
4257                                values: Map::from_iter([
4258                                    ("id".into(), Value::from(id)),
4259                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
4260                                    (
4261                                        "title".into(),
4262                                        Value::from(format!("needle{}", (index + offset) % 16)),
4263                                    ),
4264                                ]),
4265                                base_version: None,
4266                            }
4267                        }
4268                    })
4269                    .collect::<Vec<_>>();
4270                for instance in [&mut incremental, &mut reference] {
4271                    instance.mutate(mutations.clone()).unwrap();
4272                }
4273                // Most frames touch only the table without unique constraints.
4274                // Mixed frames must retain full replay for both tables.
4275                let changes = (0..3)
4276                    .map(|offset| {
4277                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
4278                        let id = format!("row{}", (index + offset) % 16);
4279                        let values = Map::from_iter([
4280                            ("id".into(), Value::from(id.clone())),
4281                            (
4282                                "project_id".into(),
4283                                Value::from(format!("p{}", (index + 1) % 3)),
4284                            ),
4285                            (
4286                                "title".into(),
4287                                Value::from(format!("remote{index}_{offset}")),
4288                            ),
4289                        ]);
4290                        ssp2::model::Change {
4291                            table_index: table_index as u16,
4292                            row_id: id.clone(),
4293                            op: if (index + offset) % 5 == 0 {
4294                                Op::Delete
4295                            } else {
4296                                Op::Upsert
4297                            },
4298                            row_version: Some(index as i64 + 2),
4299                            scopes: vec![],
4300                            row: Some(
4301                                encode_row_json(
4302                                    incremental.schema.table(&tables[table_index]).unwrap(),
4303                                    &id,
4304                                    &values,
4305                                    &incremental.encryption,
4306                                )
4307                                .unwrap(),
4308                            ),
4309                        }
4310                    })
4311                    .collect::<Vec<_>>();
4312                reference.overlay_dirty.set(true);
4313                incremental
4314                    .apply_commit_frame(&tables, &changes, None)
4315                    .unwrap();
4316                reference
4317                    .apply_commit_frame(&tables, &changes, None)
4318                    .unwrap();
4319                for table in &tables {
4320                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
4321                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
4322                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
4323                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
4324                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
4325                    }
4326                }
4327                assert_eq!(incremental.local_revision(), reference.local_revision());
4328                assert_eq!(
4329                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4330                    serde_json::to_value(reference.drain_change_batches()).unwrap()
4331                );
4332            }
4333            assert_eq!(
4334                incremental.overlay_rebuild_count.get(),
4335                if unique { 81 } else { 21 }
4336            );
4337            assert_eq!(reference.overlay_rebuild_count.get(), 81);
4338        }
4339    }
4340
4341    #[test]
4342    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
4343        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
4344            "columns":[{"name":"id", "type":"string", "nullable":false},
4345                {"name":"title", "type":"string", "nullable":false}],
4346            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
4347        let mut client =
4348            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
4349        for (id, title) in [("a", "original"), ("b", "occupied")] {
4350            client
4351                .write_base_row(
4352                    "tasks",
4353                    &vec![
4354                        Some(ColumnValue::String(id.into())),
4355                        Some(ColumnValue::String(title.into())),
4356                    ],
4357                    1,
4358                )
4359                .unwrap();
4360        }
4361        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4362        let pending = client
4363            .mutate(vec![Mutation::Upsert {
4364                table: "tasks".into(),
4365                values: Map::from_iter([
4366                    ("id".into(), Value::from("a")),
4367                    ("title".into(), Value::from("available")),
4368                ]),
4369                base_version: None,
4370            }])
4371            .unwrap();
4372        assert_eq!(
4373            client
4374                .conn
4375                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4376                    .get::<_, String>(
4377                    0
4378                ))
4379                .unwrap(),
4380            "available"
4381        );
4382        for (title, expected) in [("available", "original"), ("occupied", "available")] {
4383            let change = ssp2::model::Change {
4384                table_index: 0,
4385                row_id: "b".into(),
4386                op: Op::Upsert,
4387                row_version: Some(2),
4388                scopes: vec![],
4389                row: Some(
4390                    encode_row_json(
4391                        client.schema.table("tasks").unwrap(),
4392                        "b",
4393                        &Map::from_iter([
4394                            ("id".into(), Value::from("b")),
4395                            ("title".into(), Value::from(title)),
4396                        ]),
4397                        &client.encryption,
4398                    )
4399                    .unwrap(),
4400                ),
4401            };
4402            client
4403                .apply_commit_frame(&["tasks".into()], &[change], None)
4404                .unwrap();
4405            assert_eq!(
4406                client
4407                    .conn
4408                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4409                        .get::<_, String>(
4410                        0
4411                    ))
4412                    .unwrap(),
4413                expected
4414            );
4415            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
4416        }
4417        assert_eq!(client.overlay_rebuild_count.get(), 3);
4418    }
4419
4420    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
4421        let schema = json!({ "version": 1, "tables": [
4422            { "name":"tasks", "primaryKey":"id", "columns":[
4423                {"name":"id","type":"string","nullable":false},
4424                {"name":"project_id","type":"string","nullable":false}],
4425                "scopes":[{"pattern":"project:{project_id}"}] },
4426            { "name":"catalogue", "primaryKey":"id", "columns":[
4427                {"name":"id","type":"string","nullable":false},
4428                {"name":"project_id","type":"string","nullable":false},
4429                {"name":"title","type":"string","nullable":false}],
4430                "scopes":[{"pattern":"project:{project_id}"}],
4431                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
4432        ] });
4433        let mut client =
4434            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
4435        if unchanged_rows > 0 {
4436            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();
4437        }
4438        client.rebuild_overlay().expect("rebuild overlay");
4439        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();
4440        let mut elapsed = Vec::new();
4441        for round in 1..=rounds {
4442            let pending = client
4443                .mutate(vec![Mutation::Upsert {
4444                    table: "tasks".into(),
4445                    values: Map::from_iter([
4446                        ("id".into(), json!("t1")),
4447                        ("project_id".into(), json!("p1")),
4448                    ]),
4449                    base_version: None,
4450                }])
4451                .unwrap();
4452            let started = std::time::Instant::now();
4453            client
4454                .write_base_row(
4455                    "tasks",
4456                    &vec![
4457                        Some(ColumnValue::String("t1".into())),
4458                        Some(ColumnValue::String("p1".into())),
4459                    ],
4460                    round,
4461                )
4462                .unwrap();
4463            let mut report = SyncReport::default();
4464            client
4465                .handle_push_results(
4466                    &[Frame::PushResult {
4467                        client_commit_id: pending.clone(),
4468                        status: PushStatus::Applied,
4469                        commit_seq: Some(round),
4470                        results: vec![OpResult::Applied { op_index: 0 }],
4471                    }],
4472                    &HashSet::from([pending.as_str()]),
4473                    &HashMap::new(),
4474                    &mut report,
4475                    Some(&pending),
4476                )
4477                .unwrap();
4478            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4479            elapsed.push(started.elapsed().as_nanos());
4480        }
4481        assert_eq!(
4482            client
4483                .conn
4484                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
4485                .unwrap(),
4486            0,
4487            "unchanged catalogue must never be copied"
4488        );
4489        assert_eq!(
4490            client
4491                .conn
4492                .query_row(
4493                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
4494                    [],
4495                    |r| r.get::<_, i64>(0)
4496                )
4497                .unwrap(),
4498            unchanged_rows
4499        );
4500        assert_eq!(
4501            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
4502            json!(rounds)
4503        );
4504        elapsed
4505    }
4506
4507    #[test]
4508    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
4509        let elapsed = measure_scoped_overlay(100_000, 1);
4510        eprintln!(
4511            "table-scoped overlay with 100k unchanged rows: {} ns",
4512            elapsed[0]
4513        );
4514    }
4515
4516    #[test]
4517    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
4518    fn benchmark_scoped_overlay() {
4519        for rows in [0, 100_000, 200_000] {
4520            let mut elapsed = measure_scoped_overlay(rows, 30);
4521            elapsed.sort_unstable();
4522            eprintln!(
4523                "{}",
4524                json!({"unchangedRows": rows, "rounds": elapsed.len(),
4525                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
4526                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
4527            );
4528        }
4529    }
4530
4531    #[test]
4532    fn released_context_discards_in_flight_ack_and_server_rows() {
4533        for barrier in [
4534            "preflight",
4535            "purge",
4536            "rebootstrap",
4537            "subscription",
4538            "failed-purge-replay",
4539            "failed-purge-publish",
4540            "failed-rebootstrap",
4541        ] {
4542            let mut client = client();
4543            client.set_meta(LOG_EPOCH_KEY, "epoch");
4544            client
4545                .subscribe(
4546                    "tasks".into(),
4547                    "tasks".into(),
4548                    vec![("project_id".into(), vec!["p1".into()])],
4549                    None,
4550                )
4551                .unwrap();
4552            let commit = client
4553                .mutate(vec![Mutation::Upsert {
4554                    table: "tasks".into(),
4555                    values: Map::from_iter([
4556                        ("id".into(), json!("local")),
4557                        ("project_id".into(), json!("p1")),
4558                    ]),
4559                    base_version: None,
4560                }])
4561                .unwrap();
4562            let prepared = client.prepare_sync_round(false).unwrap();
4563            client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
4564            let payload = encode_row_json(
4565                &client.schema.tables[0],
4566                "server",
4567                &Map::from_iter([
4568                    ("id".into(), json!("server")),
4569                    ("project_id".into(), json!("p1")),
4570                ]),
4571                &client.encryption,
4572            )
4573            .unwrap();
4574            let completed = crate::CompletedSyncRound {
4575                prepared,
4576                exchange: crate::round::ExchangeResult::Reply {
4577                    transport_failed: false,
4578                    downloads: Default::default(),
4579                    response: Ok(Message {
4580                        wire_version: WIRE_VERSION,
4581                        msg_kind: MsgKind::Response,
4582                        frames: vec![
4583                            Frame::RespHeader {
4584                                required_schema_version: None,
4585                                latest_schema_version: None,
4586                                log_epoch: Some("epoch".into()),
4587                                reset_required: Some(false),
4588                            },
4589                            Frame::PushResult {
4590                                client_commit_id: commit.clone(),
4591                                status: PushStatus::Applied,
4592                                commit_seq: Some(1),
4593                                results: vec![OpResult::Applied { op_index: 0 }],
4594                            },
4595                            Frame::SubStart {
4596                                id: "tasks".into(),
4597                                status: SubStatus::Active,
4598                                reason_code: String::new(),
4599                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4600                                bootstrap: true,
4601                            },
4602                            Frame::Commit {
4603                                commit_seq: 1,
4604                                created_at_ms: 0,
4605                                actor_id: "actor".into(),
4606                                tables: vec!["tasks".into()],
4607                                changes: vec![ssp2::model::Change {
4608                                    table_index: 0,
4609                                    row_id: "server".into(),
4610                                    op: Op::Upsert,
4611                                    row_version: Some(1),
4612                                    scopes: vec![("project_id".into(), "p1".into())],
4613                                    row: Some(payload),
4614                                }],
4615                            },
4616                            Frame::SubEnd {
4617                                next_cursor: 1,
4618                                bootstrap_state: None,
4619                            },
4620                        ],
4621                    }),
4622                },
4623            };
4624            match barrier {
4625                "preflight" => client.begin_security_preflight(),
4626                "purge" => {
4627                    client
4628                        .purge_local_data(&LocalDataPurgeInput {
4629                            purge_id: "pending-round".into(),
4630                            targets: vec![LocalDataPurgeTarget {
4631                                table: "tasks".into(),
4632                                selectors: BTreeMap::from([(
4633                                    "project_id".into(),
4634                                    vec!["p1".into()],
4635                                )]),
4636                            }],
4637                        })
4638                        .unwrap();
4639                }
4640                "rebootstrap" => {
4641                    client
4642                        .rebootstrap_local_data(&LocalDataRebootstrapInput {
4643                            rebootstrap_id: "pending-round".into(),
4644                        })
4645                        .unwrap();
4646                }
4647                "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4648                    let trigger = if barrier == "failed-purge-replay" {
4649                        "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4650                    } else {
4651                        "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4652                    };
4653                    client.conn.execute_batch(trigger).unwrap();
4654                    let revision = client.local_revision();
4655                    if barrier == "failed-rebootstrap" {
4656                        assert!(client
4657                            .rebootstrap_local_data(&LocalDataRebootstrapInput {
4658                                rebootstrap_id: "failed-round".into(),
4659                            })
4660                            .is_err());
4661                    } else {
4662                        assert!(client
4663                            .purge_local_data(&LocalDataPurgeInput {
4664                                purge_id: "failed-round".into(),
4665                                targets: vec![LocalDataPurgeTarget {
4666                                    table: "tasks".into(),
4667                                    selectors: BTreeMap::from([(
4668                                        "project_id".into(),
4669                                        vec!["p1".into()]
4670                                    )]),
4671                                }],
4672                            })
4673                            .is_err());
4674                    }
4675                    assert_eq!(client.local_revision(), revision);
4676                    assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4677                    client
4678                        .conn
4679                        .execute_batch("DROP TRIGGER fail_local")
4680                        .unwrap();
4681                    let applied = client.apply_sync_round(completed);
4682                    assert!(
4683                        matches!(
4684                            applied,
4685                            crate::AppliedSyncRound::Complete {
4686                                outcome: SyncOutcome::Ok(_),
4687                                ..
4688                            }
4689                        ),
4690                        "{barrier}"
4691                    );
4692                    assert!(client.pending_commit_ids().is_empty());
4693                    assert_eq!(
4694                        client
4695                            .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4696                            .unwrap()
4697                            .len(),
4698                        1
4699                    );
4700                    continue;
4701                }
4702                "subscription" => {
4703                    client.unsubscribe("tasks");
4704                    client
4705                        .subscribe(
4706                            "tasks".into(),
4707                            "tasks".into(),
4708                            vec![("project_id".into(), vec!["p2".into()])],
4709                            None,
4710                        )
4711                        .unwrap();
4712                }
4713                _ => unreachable!(),
4714            }
4715            let pending_before_reply = client.pending_commit_ids();
4716            let revision = client.local_revision();
4717            let applied = client.apply_sync_round(completed);
4718            assert!(
4719                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4720            );
4721            assert_eq!(client.local_revision(), revision);
4722            assert!(client
4723                .conn
4724                .query_row(
4725                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4726                    [],
4727                    |r| r.get::<_, bool>(0)
4728                )
4729                .unwrap());
4730            assert_eq!(client.pending_commit_ids(), pending_before_reply);
4731        }
4732    }
4733
4734    #[test]
4735    fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4736        struct DeadlineRecording {
4737            budget: std::time::Duration,
4738            seen: Vec<Option<std::time::Instant>>,
4739        }
4740        impl Transport for DeadlineRecording {
4741            fn round_deadline(&self) -> Option<std::time::Duration> {
4742                Some(self.budget)
4743            }
4744            fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4745                self.seen.push(deadline);
4746            }
4747            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4748                Err(TransportError::new("transport.failed", "injected"))
4749            }
4750            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4751                self.sync(request)
4752            }
4753            fn download_segment(
4754                &mut self,
4755                _: &SegmentRequest,
4756                _: &mut dyn FnMut(u64),
4757            ) -> Result<Vec<u8>, TransportError> {
4758                Err(TransportError::new("transport.failed", "injected"))
4759            }
4760            fn blob_upload_grant(
4761                &mut self,
4762                _: &str,
4763                _: u64,
4764                _: Option<&str>,
4765            ) -> Result<crate::BlobUploadGrant, TransportError> {
4766                Ok(crate::BlobUploadGrant::Present)
4767            }
4768            fn realtime_connect(&mut self) -> Result<(), TransportError> {
4769                Ok(())
4770            }
4771            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4772                Ok(())
4773            }
4774            fn realtime_close(&mut self) -> Result<(), TransportError> {
4775                Ok(())
4776            }
4777        }
4778
4779        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4780            {"name":"id","type":"string","nullable":false},
4781            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4782        let mut client =
4783            SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4784        client.set_meta(LOG_EPOCH_KEY, "epoch");
4785        client
4786            .upload_blob(b"pending upload", Some("text/plain".into()), None)
4787            .unwrap();
4788
4789        let mut transport = DeadlineRecording {
4790            budget: std::time::Duration::from_secs(30),
4791            seen: Vec::new(),
4792        };
4793        let prepared = client.prepare_sync_round(false).unwrap();
4794        assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4795        // Upload exchange -> Continue (the pin removal is persisted by apply).
4796        let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4797            crate::AppliedSyncRound::Continue(prepared) => prepared,
4798            crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4799        };
4800        // Main request exchange -> Complete.
4801        let completed = prepared.exchange(&mut transport);
4802        let anchor = completed
4803            .prepared
4804            .round_deadline_at
4805            .expect("the upload round anchors a deadline");
4806        assert!(matches!(
4807            client.apply_sync_round(completed),
4808            crate::AppliedSyncRound::Complete { .. }
4809        ));
4810        assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4811        assert_eq!(
4812            transport.seen[0],
4813            Some(anchor),
4814            "the upload anchors the round"
4815        );
4816        assert_eq!(
4817            transport.seen[1], None,
4818            "the upload exchange clears its scope"
4819        );
4820        assert_eq!(
4821            transport.seen[2],
4822            Some(anchor),
4823            "a continuation reuses the carried anchor instead of refreshing it"
4824        );
4825        assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4826
4827        // A fresh round anchors a new deadline from the transport's (now larger)
4828        // budget and never inherits the old one; the clock granule is irrelevant.
4829        transport.budget = std::time::Duration::from_secs(60);
4830        let prepared = client.prepare_sync_round(false).unwrap();
4831        let completed = prepared.exchange(&mut transport);
4832        let fresh = completed
4833            .prepared
4834            .round_deadline_at
4835            .expect("the next round anchors a deadline");
4836        client.apply_sync_round(completed);
4837        assert!(
4838            fresh >= anchor + std::time::Duration::from_secs(30),
4839            "the next round anchors a fresh deadline from the new budget"
4840        );
4841        assert_eq!(transport.seen[4], Some(fresh));
4842        assert_eq!(
4843            transport.seen[5], None,
4844            "the new round clears its scope too"
4845        );
4846    }
4847
4848    /// Records the round-deadline traffic so a test can prove whether a round
4849    /// asked for a budget and whether it ever scoped an anchor.
4850    #[derive(Default)]
4851    struct RoundDeadlineProbe {
4852        budget: Option<std::time::Duration>,
4853        budget_queries: std::cell::Cell<usize>,
4854        scoped: Vec<Option<std::time::Instant>>,
4855    }
4856
4857    impl Transport for RoundDeadlineProbe {
4858        fn round_deadline(&self) -> Option<std::time::Duration> {
4859            self.budget_queries.set(self.budget_queries.get() + 1);
4860            self.budget
4861        }
4862        fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4863            self.scoped.push(deadline);
4864        }
4865        fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4866            Err(TransportError::new("transport.failed", "injected"))
4867        }
4868        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4869            self.sync(request)
4870        }
4871        fn download_segment(
4872            &mut self,
4873            _: &SegmentRequest,
4874            _: &mut dyn FnMut(u64),
4875        ) -> Result<Vec<u8>, TransportError> {
4876            Err(TransportError::new("transport.failed", "injected"))
4877        }
4878        fn blob_upload_grant(
4879            &mut self,
4880            _: &str,
4881            _: u64,
4882            _: Option<&str>,
4883        ) -> Result<crate::BlobUploadGrant, TransportError> {
4884            Ok(crate::BlobUploadGrant::Present)
4885        }
4886        fn realtime_connect(&mut self) -> Result<(), TransportError> {
4887            Ok(())
4888        }
4889        fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4890            Ok(())
4891        }
4892        fn realtime_close(&mut self) -> Result<(), TransportError> {
4893            Ok(())
4894        }
4895    }
4896
4897    #[test]
4898    fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
4899        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4900            {"name":"id","type":"string","nullable":false},
4901            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4902        let mut client =
4903            SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
4904        client.set_meta(LOG_EPOCH_KEY, "epoch");
4905        client
4906            .upload_blob(b"pending upload", Some("text/plain".into()), None)
4907            .unwrap();
4908        client
4909            .conn
4910            .execute_batch("DELETE FROM _syncular_blobs")
4911            .unwrap();
4912        let prepared = client.prepare_sync_round(false).unwrap();
4913        assert!(
4914            prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
4915            "the staged body must fail locally"
4916        );
4917        let mut transport = RoundDeadlineProbe {
4918            budget: Some(std::time::Duration::from_secs(30)),
4919            ..Default::default()
4920        };
4921        let completed = prepared.exchange(&mut transport);
4922        assert!(matches!(
4923            completed.exchange,
4924            crate::round::ExchangeResult::Upload { result: Err(_), .. }
4925        ));
4926        assert_eq!(
4927            transport.budget_queries.get(),
4928            0,
4929            "a round with no network work never asks for a budget"
4930        );
4931        assert_eq!(transport.scoped, vec![None]);
4932    }
4933
4934    #[test]
4935    fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
4936        struct HugeBudget;
4937        impl Transport for HugeBudget {
4938            fn round_deadline(&self) -> Option<std::time::Duration> {
4939                Some(std::time::Duration::MAX)
4940            }
4941            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4942                panic!("no network for an invalid budget")
4943            }
4944            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4945                self.sync(request)
4946            }
4947            fn download_segment(
4948                &mut self,
4949                _: &SegmentRequest,
4950                _: &mut dyn FnMut(u64),
4951            ) -> Result<Vec<u8>, TransportError> {
4952                panic!("no network for an invalid budget")
4953            }
4954            fn realtime_connect(&mut self) -> Result<(), TransportError> {
4955                Ok(())
4956            }
4957            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4958                Ok(())
4959            }
4960            fn realtime_close(&mut self) -> Result<(), TransportError> {
4961                Ok(())
4962            }
4963        }
4964
4965        let mut client = client();
4966        client.set_meta(LOG_EPOCH_KEY, "epoch");
4967        let prepared = client.prepare_sync_round(false).unwrap();
4968        let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
4969            crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
4970            crate::AppliedSyncRound::Continue(_) => {
4971                panic!("an unrepresentable budget must not continue")
4972            }
4973        };
4974        match outcome {
4975            SyncOutcome::Failed {
4976                error_code,
4977                message,
4978                ..
4979            } => {
4980                assert_eq!(error_code, "sync.invalid_request");
4981                assert_eq!(message, "transport round deadline is out of range");
4982            }
4983            _ => panic!("an unrepresentable budget must fail synchronously"),
4984        }
4985    }
4986
4987    #[test]
4988    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
4989        for failure in ["visible", "revision", "commit"] {
4990            let mut client = client();
4991            let id = client
4992                .mutate(vec![Mutation::Upsert {
4993                    table: "tasks".into(),
4994                    values: Map::from_iter([
4995                        ("id".into(), Value::from("t1")),
4996                        ("project_id".into(), Value::from("pending")),
4997                    ]),
4998                    base_version: None,
4999                }])
5000                .unwrap();
5001            client.drain_change_batches();
5002            let revision = client.local_revision();
5003            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5004            let trigger = match failure {
5005                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
5006                "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"),
5007                _ => "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(),
5008            };
5009            client.conn.execute_batch(&trigger).unwrap();
5010            let change = ssp2::model::Change {
5011                table_index: 0,
5012                row_id: "t1".into(),
5013                op: Op::Upsert,
5014                row_version: Some(2),
5015                scopes: vec![],
5016                row: Some(
5017                    encode_row_json(
5018                        client.schema.table("tasks").unwrap(),
5019                        "t1",
5020                        &Map::from_iter([
5021                            ("id".into(), Value::from("t1")),
5022                            ("project_id".into(), Value::from("remote")),
5023                        ]),
5024                        &client.encryption,
5025                    )
5026                    .unwrap(),
5027                ),
5028            };
5029            assert!(client
5030                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
5031                .is_err());
5032            assert!(client.conn.is_autocommit());
5033            assert!(!client.overlay_dirty.get());
5034            assert_eq!(client.local_revision(), revision);
5035            assert!(client.drain_change_batches().is_empty());
5036            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5037            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5038            assert!(client
5039                .query("SELECT * FROM _syncular_base_tasks", &[])
5040                .unwrap()
5041                .is_empty());
5042            client
5043                .conn
5044                .execute_batch("DROP TRIGGER fail_pending")
5045                .unwrap();
5046            client
5047                .apply_commit_frame(&["tasks".into()], &[change], None)
5048                .unwrap();
5049            assert_eq!(client.local_revision(), revision + 1);
5050            assert_eq!(client.pending_commit_ids(), vec![id]);
5051            // The overlay keeps the pending write visible; the row now carries
5052            // the server row's version it is layered on (§7.1).
5053            let mut expected = before[0].clone();
5054            expected.insert("_syncular_version".to_owned(), Value::from(2));
5055            assert_eq!(
5056                client.query("SELECT * FROM tasks", &[]).unwrap(),
5057                vec![expected]
5058            );
5059            assert_eq!(
5060                client
5061                    .conn
5062                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
5063                        .get::<_, String>(0))
5064                    .unwrap(),
5065                "remote"
5066            );
5067        }
5068    }
5069
5070    #[test]
5071    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
5072        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
5073            "columns": [
5074                {"name":"id", "type":"string", "nullable":false},
5075                {"name":"project_id", "type":"string", "nullable":false},
5076                {"name":"title", "type":"string", "nullable":false}],
5077            "scopes": [{"pattern":"project:{project_id}"}],
5078            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
5079            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
5080        let mut incremental =
5081            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5082        let mut reference =
5083            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5084        for instance in [&mut incremental, &mut reference] {
5085            for index in 0..16 {
5086                instance
5087                    .write_base_row(
5088                        "tasks",
5089                        &vec![
5090                            Some(ColumnValue::String(format!("task{index}"))),
5091                            Some(ColumnValue::String("p0".to_owned())),
5092                            Some(ColumnValue::String(format!("base{index}"))),
5093                        ],
5094                        1,
5095                    )
5096                    .unwrap();
5097            }
5098            instance
5099                .rebuild_overlay_if_dirty()
5100                .expect("rebuild overlay");
5101            instance.overlay_rebuild_count.set(0);
5102        }
5103        for index in 0..100 {
5104            let mutations = (0..1 + index % 4)
5105                .map(|offset| {
5106                    let id = format!("task{}", (index + offset) % 24);
5107                    if (index + offset) % 7 == 0 {
5108                        Mutation::Delete {
5109                            table: "tasks".to_owned(),
5110                            row_id: id,
5111                            base_version: None,
5112                        }
5113                    } else {
5114                        Mutation::Upsert {
5115                            table: "tasks".to_owned(),
5116                            values: Map::from_iter([
5117                                ("id".to_owned(), Value::from(id)),
5118                                (
5119                                    "project_id".to_owned(),
5120                                    Value::from(format!("p{}", index % 2)),
5121                                ),
5122                                (
5123                                    "title".to_owned(),
5124                                    Value::from(format!("needle{}", (index + offset) % 24)),
5125                                ),
5126                            ]),
5127                            base_version: None,
5128                        }
5129                    }
5130                })
5131                .collect::<Vec<_>>();
5132            reference.overlay_dirty.set(true);
5133            incremental.mutate(mutations.clone()).unwrap();
5134            reference.mutate(mutations).unwrap();
5135            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
5136                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
5137                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
5138            }
5139            assert_eq!(incremental.local_revision(), reference.local_revision());
5140            assert_eq!(
5141                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
5142                serde_json::to_value(reference.drain_change_batches()).unwrap()
5143            );
5144        }
5145        assert_eq!(incremental.outbox.len(), 100);
5146        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
5147        assert_eq!(reference.overlay_rebuild_count.get(), 100);
5148    }
5149
5150    #[test]
5151    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
5152        for fail_at in ["outbox", "revision"] {
5153            let mut client = client();
5154            let values = Map::from_iter([
5155                ("id".to_owned(), Value::from("t1")),
5156                ("project_id".to_owned(), Value::from("p1")),
5157            ]);
5158            let mutation = Mutation::Upsert {
5159                table: "tasks".to_owned(),
5160                values,
5161                base_version: None,
5162            };
5163            let trigger = if fail_at == "outbox" {
5164                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
5165            } else {
5166                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
5167            };
5168            client.conn.execute_batch(&trigger).unwrap();
5169            let revision = client.local_revision();
5170            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
5171            assert_eq!(client.local_revision(), revision);
5172            assert!(client.outbox.is_empty());
5173            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5174            assert_eq!(
5175                client
5176                    .conn
5177                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5178                        .get::<_, i64>(0))
5179                    .unwrap(),
5180                0
5181            );
5182            assert!(client.drain_change_batches().is_empty());
5183            assert!(!client.overlay_dirty.get());
5184            client
5185                .conn
5186                .execute_batch("DROP TRIGGER fail_append")
5187                .unwrap();
5188            client.mutate(vec![mutation]).unwrap();
5189            assert_eq!(client.outbox.len(), 1);
5190            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
5191        }
5192    }
5193
5194    #[test]
5195    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
5196        let mut client = client();
5197        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
5198        let values = Map::from_iter([
5199            ("id".to_owned(), Value::from("t1")),
5200            ("project_id".to_owned(), Value::from("p1")),
5201        ]);
5202        let payload = encode_row_json(
5203            client.schema.table("tasks").unwrap(),
5204            "t1",
5205            &values,
5206            &client.encryption,
5207        )
5208        .unwrap();
5209        let first = ssp2::model::Change {
5210            table_index: 0,
5211            row_id: "t1".to_owned(),
5212            op: Op::Upsert,
5213            row_version: Some(1),
5214            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
5215            row: Some(payload),
5216        };
5217        let mut invalid = first.clone();
5218        invalid.table_index = 1;
5219        let revision = client.local_revision();
5220        client.begin_observation("failed_mirror").unwrap();
5221        assert!(client
5222            .apply_commit_changes(&tables, &[first, invalid])
5223            .is_err());
5224        client.rollback_observation("failed_mirror");
5225        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
5226        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5227        assert_eq!(
5228            client
5229                .conn
5230                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
5231                    .get::<_, i64>(
5232                    0
5233                ))
5234                .unwrap(),
5235            0
5236        );
5237        assert_eq!(client.local_revision(), revision);
5238        assert!(client.drain_change_batches().is_empty());
5239    }
5240
5241    #[test]
5242    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
5243        for failure in ["journal", "revision", "commit", "later-frame"] {
5244            let mut client = client();
5245            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5246            for id in ["first", "second"] {
5247                client
5248                    .mutate(vec![Mutation::Upsert {
5249                        table: "tasks".to_owned(),
5250                        values: Map::from_iter([
5251                            ("id".to_owned(), Value::from(id)),
5252                            ("project_id".to_owned(), Value::from("p1")),
5253                        ]),
5254                        base_version: None,
5255                    }])
5256                    .unwrap();
5257            }
5258            let (_, meta) = client.build_request(false).unwrap();
5259            // A malicious or stale response must not drain an unsent local commit.
5260            client
5261                .mutate(vec![Mutation::Upsert {
5262                    table: "tasks".to_owned(),
5263                    values: Map::from_iter([
5264                        ("id".to_owned(), Value::from("later")),
5265                        ("project_id".to_owned(), Value::from("p1")),
5266                    ]),
5267                    base_version: None,
5268                }])
5269                .unwrap();
5270            let ids = client.pending_commit_ids();
5271            let rows = client
5272                .query("SELECT * FROM tasks ORDER BY id", &[])
5273                .unwrap();
5274            let revision = client.local_revision();
5275            client.drain_change_batches();
5276            if failure != "later-frame" {
5277                client.conn.execute_batch(match failure {
5278                    "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",
5279                    "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",
5280                    _ => "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",
5281                }).unwrap();
5282            }
5283            let mut response = Message {
5284                wire_version: WIRE_VERSION,
5285                msg_kind: MsgKind::Response,
5286                frames: vec![Frame::RespHeader {
5287                    required_schema_version: None,
5288                    latest_schema_version: None,
5289                    log_epoch: Some("epoch-1".to_owned()),
5290                    reset_required: Some(false),
5291                }],
5292            };
5293            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
5294                response.frames.push(Frame::PushResult {
5295                    client_commit_id: id.to_owned(),
5296                    status: PushStatus::Applied,
5297                    commit_seq: Some(1),
5298                    results: vec![OpResult::Applied { op_index: 0 }],
5299                });
5300            }
5301            if failure == "later-frame" {
5302                response.frames.push(Frame::Error {
5303                    code: "sync.invalid_request".to_owned(),
5304                    message: "later error".to_owned(),
5305                    category: "protocol".to_owned(),
5306                    retryable: false,
5307                    recommended_action: "retry".to_owned(),
5308                    details: None,
5309                });
5310            }
5311            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5312            let outcome = client.process_response(&mut transport, response.clone(), &meta);
5313            // §1.4 rule 4: the deferred foreign key of "commit" fails the
5314            // response's coalesced COMMIT instead of the run's RELEASE.
5315            let expected = match failure {
5316                "later-frame" => "sync.invalid_request",
5317                "commit" => "storage.failed",
5318                _ => "client.outcome_persistence_failed",
5319            };
5320            assert!(
5321                matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5322                "{failure}: {outcome:?}"
5323            );
5324            if failure == "later-frame" {
5325                assert_eq!(client.pending_commit_ids(), ids[2..]);
5326                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5327                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
5328                let batches = client.drain_change_batches();
5329                assert_eq!(batches.len(), 1);
5330                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
5331                assert_eq!(client.local_revision(), revision + 1);
5332                response.frames.pop();
5333            } else {
5334                assert_eq!(client.pending_commit_ids(), ids);
5335                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5336                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
5337                assert_eq!(client.local_revision(), revision);
5338                assert!(client.drain_change_batches().is_empty());
5339                assert_eq!(
5340                    client
5341                        .query("SELECT * FROM tasks ORDER BY id", &[])
5342                        .unwrap(),
5343                    rows
5344                );
5345                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
5346            }
5347            for frame in &mut response.frames {
5348                if let Frame::PushResult { status, .. } = frame {
5349                    *status = PushStatus::Cached;
5350                }
5351            }
5352            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
5353            else {
5354                panic!("retry failed");
5355            };
5356            assert_eq!(
5357                report.applied,
5358                if failure == "later-frame" {
5359                    vec![]
5360                } else {
5361                    ids[..2].to_vec()
5362                }
5363            );
5364            assert_eq!(client.pending_commit_ids(), ids[2..]);
5365            let batches = client.drain_change_batches();
5366            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
5367            if let Some(batch) = batches.first() {
5368                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
5369            }
5370            assert_eq!(client.local_revision(), revision + 1);
5371        }
5372    }
5373
5374    #[test]
5375    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
5376        for failure in ["revision", "journal", "commit"] {
5377            for status in ["applied", "cached", "rejected", "conflict"] {
5378                let mut client = client();
5379                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5380                for id in ["first", "later"] {
5381                    client
5382                        .mutate(vec![Mutation::Upsert {
5383                            table: "tasks".to_owned(),
5384                            values: Map::from_iter([
5385                                ("id".to_owned(), Value::from(id)),
5386                                ("project_id".to_owned(), Value::from("p1")),
5387                            ]),
5388                            base_version: None,
5389                        }])
5390                        .unwrap();
5391                }
5392                let (_, meta) = client.build_request(false).unwrap();
5393                let ids = client.pending_commit_ids();
5394                let rows = client
5395                    .query("SELECT * FROM tasks ORDER BY id", &[])
5396                    .unwrap();
5397                let revision = client.local_revision();
5398                client.drain_change_batches();
5399                let (push_status, results) = match status {
5400                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
5401                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
5402                    "conflict" => (
5403                        PushStatus::Rejected,
5404                        vec![OpResult::Conflict {
5405                            op_index: 0,
5406                            code: "sync.version_conflict".to_owned(),
5407                            message: "conflict".to_owned(),
5408                            server_version: 1,
5409                            server_row: encode_row_json(
5410                                client.schema.table("tasks").unwrap(),
5411                                "first",
5412                                &Map::from_iter([
5413                                    ("id".to_owned(), Value::from("first")),
5414                                    ("project_id".to_owned(), Value::from("p1")),
5415                                ]),
5416                                &client.encryption,
5417                            )
5418                            .unwrap(),
5419                            conflict_columns: Vec::new(),
5420                        }],
5421                    ),
5422                    _ => (
5423                        PushStatus::Rejected,
5424                        vec![OpResult::Error {
5425                            op_index: 0,
5426                            code: "sync.validation_failed".to_owned(),
5427                            message: "rejected".to_owned(),
5428                            retryable: false,
5429                        }],
5430                    ),
5431                };
5432                let response = Message {
5433                    wire_version: WIRE_VERSION,
5434                    msg_kind: MsgKind::Response,
5435                    frames: vec![
5436                        Frame::RespHeader {
5437                            required_schema_version: None,
5438                            latest_schema_version: None,
5439                            log_epoch: Some("epoch-1".to_owned()),
5440                            reset_required: Some(false),
5441                        },
5442                        Frame::PushResult {
5443                            client_commit_id: ids[0].clone(),
5444                            status: push_status,
5445                            commit_seq: Some(1),
5446                            results,
5447                        },
5448                    ],
5449                };
5450                client.conn.execute_batch(match failure {
5451                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
5452                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
5453                    _ => "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",
5454                }).unwrap();
5455                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5456                let outcome = client.process_response(&mut transport, response.clone(), &meta);
5457                assert_eq!(
5458                    client.pending_commit_ids(),
5459                    ids,
5460                    "{failure}/{status}: pending identity"
5461                );
5462                assert_eq!(
5463                    client.local_revision(),
5464                    revision,
5465                    "{failure}/{status}: revision"
5466                );
5467                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5468                assert!(client.conflicts().is_empty());
5469                assert!(client.rejections().is_empty());
5470                assert!(client.drain_change_batches().is_empty());
5471                assert_eq!(
5472                    client
5473                        .query("SELECT * FROM tasks ORDER BY id", &[])
5474                        .unwrap(),
5475                    rows
5476                );
5477                // §1.4 rule 4: the deferred foreign key of "commit" fails the
5478                // response's coalesced COMMIT instead of the result's RELEASE.
5479                let expected = if failure == "commit" {
5480                    "storage.failed"
5481                } else {
5482                    "client.outcome_persistence_failed"
5483                };
5484                assert!(
5485                    matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5486                    "{failure}/{status}: failed round"
5487                );
5488                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
5489                assert!(matches!(
5490                    client.process_response(&mut transport, response, &meta),
5491                    SyncOutcome::Ok(_)
5492                ));
5493                assert_eq!(client.pending_commit_ids(), ids[1..]);
5494                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5495                assert_eq!(client.drain_change_batches().len(), 1);
5496            }
5497        }
5498    }
5499
5500    #[test]
5501    fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
5502        let mut client = client();
5503        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5504        for index in 0..4 {
5505            client
5506                .mutate(vec![Mutation::Upsert {
5507                    table: "tasks".to_owned(),
5508                    values: Map::from_iter([
5509                        ("id".to_owned(), Value::from(format!("task-{index}"))),
5510                        ("project_id".to_owned(), Value::from("project-1")),
5511                    ]),
5512                    base_version: None,
5513                }])
5514                .expect("queue commit");
5515        }
5516
5517        let (_, request_meta) = client.build_request(false).unwrap();
5518        let ids = &request_meta.pushed_ids;
5519        assert_eq!(ids.len(), 4);
5520        let response = Message {
5521            wire_version: WIRE_VERSION,
5522            msg_kind: MsgKind::Response,
5523            frames: vec![
5524                Frame::RespHeader {
5525                    required_schema_version: None,
5526                    latest_schema_version: None,
5527                    log_epoch: Some("epoch-1".to_owned()),
5528                    reset_required: Some(false),
5529                },
5530                Frame::PushResult {
5531                    client_commit_id: ids[0].clone(),
5532                    status: PushStatus::Applied,
5533                    commit_seq: Some(1),
5534                    results: vec![OpResult::Applied { op_index: 0 }],
5535                },
5536                Frame::PushResult {
5537                    client_commit_id: ids[1].clone(),
5538                    status: PushStatus::Cached,
5539                    commit_seq: Some(2),
5540                    results: vec![OpResult::Applied { op_index: 0 }],
5541                },
5542                Frame::PushResult {
5543                    client_commit_id: ids[2].clone(),
5544                    status: PushStatus::Rejected,
5545                    commit_seq: None,
5546                    results: vec![OpResult::Error {
5547                        op_index: 0,
5548                        code: "sync.validation_failed".to_owned(),
5549                        message: "rejected".to_owned(),
5550                        retryable: false,
5551                    }],
5552                },
5553                Frame::PushResult {
5554                    client_commit_id: ids[3].clone(),
5555                    status: PushStatus::Rejected,
5556                    commit_seq: None,
5557                    results: vec![OpResult::Error {
5558                        op_index: 0,
5559                        code: "sync.idempotency_cache_miss".to_owned(),
5560                        message: "retry".to_owned(),
5561                        retryable: true,
5562                    }],
5563                },
5564            ],
5565        };
5566        let mut transport =
5567            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
5568
5569        client.overlay_rebuild_count.set(0);
5570        client.outcome_prune_count.set(0);
5571        let outcome = client.process_response(&mut transport, response, &request_meta);
5572        let SyncOutcome::Ok(report) = outcome else {
5573            panic!("mixed push-result response failed");
5574        };
5575
5576        assert_eq!(report.applied, ids[..2]);
5577        assert_eq!(report.rejected, ids[2..3]);
5578        assert_eq!(report.retryable, ids[3..4]);
5579        assert_eq!(client.pending_commit_ids(), ids[3..4]);
5580        assert_eq!(
5581            client
5582                .query("SELECT id FROM tasks ORDER BY id", &[])
5583                .expect("query visible overlay"),
5584            ["task-0", "task-1", "task-3"]
5585                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
5586                .to_vec()
5587        );
5588        // The successful ACK run and final rejection have distinct durable boundaries.
5589        assert_eq!(client.overlay_rebuild_count.get(), 2);
5590        assert_eq!(client.outcome_prune_count.get(), 1);
5591    }
5592
5593    fn replay_schema(fts: bool) -> Value {
5594        json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
5595            {"name":"id","type":"string","nullable":false},
5596            {"name":"project_id","type":"string","nullable":false},
5597            {"name":"title","type":"string","nullable":false},
5598            {"name":"done","type":"boolean","nullable":false}
5599        ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5600    }
5601
5602    #[test]
5603    fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5604        for schema_reset in [false, true] {
5605            let mut schema = replay_schema(false);
5606            let mut client =
5607                SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5608            let id = client
5609                .mutate(vec![Mutation::Upsert {
5610                    table: "tasks".into(),
5611                    values: Map::from_iter([
5612                        ("id".into(), json!("queued")),
5613                        ("project_id".into(), json!("p1")),
5614                        ("title".into(), json!("old")),
5615                        ("done".into(), json!(false)),
5616                    ]),
5617                    base_version: None,
5618                }])
5619                .unwrap();
5620            client.set_meta(LOG_EPOCH_KEY, "old");
5621            let revision = client.local_revision();
5622            let needed = client.sync_needed;
5623            client.drain_change_batches();
5624            client.drain_sync_intents();
5625            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();
5626            if schema_reset {
5627                schema["version"] = json!(2);
5628                schema["tables"][0]["columns"]
5629                    .as_array_mut()
5630                    .unwrap()
5631                    .retain(|column| column["name"] != "title");
5632                client.schema = parse_schema_json(&schema).unwrap();
5633                assert!(client.run_schema_reset().is_err());
5634            } else {
5635                assert!(client.run_log_epoch_reset("new").is_err());
5636            }
5637            assert!(!client.upgrading);
5638            assert_eq!(client.sync_needed, needed);
5639            assert_eq!(client.local_revision(), revision);
5640            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5641            assert!(client.rejections.is_empty());
5642            assert!(client.commit_outcome(&id).unwrap().is_none());
5643            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5644            assert_eq!(
5645                client
5646                    .query("SELECT title FROM tasks WHERE id='queued'", &[])
5647                    .unwrap()[0]["title"],
5648                "old"
5649            );
5650            assert_eq!(
5651                client
5652                    .conn
5653                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5654                        .get::<_, i64>(0))
5655                    .unwrap(),
5656                1
5657            );
5658            assert!(client.drain_change_batches().is_empty());
5659            assert!(client.drain_sync_intents().is_empty());
5660            assert!(client.conn.is_autocommit());
5661        }
5662    }
5663
5664    #[test]
5665    fn cached_blob_cleanup_propagates_delete_failures() {
5666        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5667            {"name":"id","type":"string","nullable":false},
5668            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5669        let mut client =
5670            SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5671        client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5672        client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5673        client
5674            .subscribe("attachments".into(), "attachments".into(), vec![], None)
5675            .unwrap();
5676        client.subs[0].effective = Some(vec![]);
5677        client.persist_sub(&client.subs[0]).unwrap();
5678        let revision = client.local_revision();
5679        let (_, meta) = client.build_request(false).unwrap();
5680        let mut transport = CountingRealtimeTransport::default();
5681        let mut report = SyncReport::default();
5682        assert!(client
5683            .process_section(
5684                &mut transport,
5685                "attachments",
5686                SubStatus::Revoked,
5687                "sync.scope_revoked",
5688                vec![],
5689                vec![],
5690                Some((0, None)),
5691                &meta,
5692                &mut report,
5693                &HashSet::new()
5694            )
5695            .is_err());
5696        assert_eq!(client.subs[0].state, SubState::Active);
5697        assert_eq!(client.local_revision(), revision);
5698        assert!(report.revoked.is_empty());
5699        assert!(client.conn.is_autocommit());
5700        client
5701            .conn
5702            .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5703            .unwrap();
5704        assert!(client
5705            .process_section(
5706                &mut transport,
5707                "attachments",
5708                SubStatus::Revoked,
5709                "sync.scope_revoked",
5710                vec![],
5711                vec![],
5712                Some((0, None)),
5713                &meta,
5714                &mut report,
5715                &HashSet::new()
5716            )
5717            .is_ok());
5718        assert_eq!(client.subs[0].state, SubState::Revoked);
5719        assert_eq!(
5720            client
5721                .conn
5722                .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5723                    .get::<_, i64>(0))
5724                .unwrap(),
5725            0
5726        );
5727    }
5728
5729    #[test]
5730    fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5731        let path =
5732            std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5733        let schema = replay_schema(false);
5734        let mut client = SyncClient::open_path(
5735            "replay".into(),
5736            &schema,
5737            ClientLimits::default(),
5738            path.to_str().unwrap(),
5739        )
5740        .unwrap();
5741        let ids = ["early", "bad"].map(|id| {
5742            client
5743                .mutate(vec![Mutation::Upsert {
5744                    table: "tasks".into(),
5745                    values: Map::from_iter([
5746                        ("id".into(), json!(id)),
5747                        ("project_id".into(), json!("p1")),
5748                        ("title".into(), json!(id)),
5749                        ("done".into(), json!(false)),
5750                    ]),
5751                    base_version: None,
5752                }])
5753                .unwrap()
5754        });
5755        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();
5756        let revision = client.local_revision();
5757        drop(client);
5758        let error = SyncClient::open_path(
5759            "replay".into(),
5760            &schema,
5761            ClientLimits::default(),
5762            path.to_str().unwrap(),
5763        )
5764        .err()
5765        .expect("reopen must fail");
5766        assert!(error.contains("replay failed"));
5767        let conn = Connection::open(&path).unwrap();
5768        assert_eq!(
5769            conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5770                .unwrap(),
5771            0
5772        );
5773        assert_eq!(
5774            conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5775                .get::<_, i64>(0))
5776                .unwrap(),
5777            2
5778        );
5779        assert_eq!(
5780            meta_get(&conn, LOCAL_REVISION_KEY),
5781            Some(revision.to_string())
5782        );
5783        conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5784        drop(conn);
5785        let reopened = SyncClient::open_path(
5786            "replay".into(),
5787            &schema,
5788            ClientLimits::default(),
5789            path.to_str().unwrap(),
5790        )
5791        .unwrap();
5792        assert_eq!(reopened.pending_commit_ids(), ids);
5793        assert_eq!(
5794            reopened
5795                .query("SELECT id FROM tasks ORDER BY id", &[])
5796                .unwrap()
5797                .len(),
5798            2
5799        );
5800        drop(reopened);
5801        std::fs::remove_file(path).unwrap();
5802    }
5803
5804    #[test]
5805    fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5806        let schema = replay_schema(false);
5807        let mut client =
5808            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5809        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5810        client
5811            .write_base_row(
5812                "tasks",
5813                &vec![
5814                    Some(ColumnValue::String("queued".into())),
5815                    Some(ColumnValue::String("p1".into())),
5816                    Some(ColumnValue::String("server".into())),
5817                    Some(ColumnValue::Boolean(false)),
5818                ],
5819                1,
5820            )
5821            .unwrap();
5822        client.rebuild_overlay().unwrap();
5823        let id = client
5824            .patch(
5825                "tasks",
5826                "queued",
5827                Map::from_iter([("title".into(), json!("local"))]),
5828                Some(1),
5829            )
5830            .unwrap();
5831        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5832        let base = client
5833            .query("SELECT * FROM _syncular_base_tasks", &[])
5834            .unwrap();
5835        let revision = client.local_revision();
5836        client.drain_change_batches();
5837        client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5838        let remote = vec![
5839            Some(ColumnValue::String("queued".into())),
5840            Some(ColumnValue::String("p1".into())),
5841            Some(ColumnValue::String("remote".into())),
5842            Some(ColumnValue::Boolean(false)),
5843        ];
5844        let bytes = crate::values::encode_row_json(
5845            client.schema.table("tasks").unwrap(),
5846            "queued",
5847            &Map::from_iter(
5848                client
5849                    .schema
5850                    .table("tasks")
5851                    .unwrap()
5852                    .columns
5853                    .iter()
5854                    .zip(&remote)
5855                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
5856            ),
5857            &client.encryption,
5858        )
5859        .unwrap();
5860        let change = ssp2::model::Change {
5861            table_index: 0,
5862            op: Op::Upsert,
5863            row_id: "queued".into(),
5864            row_version: Some(2),
5865            scopes: vec![("project_id".into(), "p1".into())],
5866            row: Some(bytes),
5867        };
5868        assert!(client
5869            .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
5870            .is_err());
5871        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5872        assert_eq!(
5873            client
5874                .query("SELECT * FROM _syncular_base_tasks", &[])
5875                .unwrap(),
5876            base
5877        );
5878        assert_eq!(client.local_revision(), revision);
5879        assert!(client.drain_change_batches().is_empty());
5880        let response = Message {
5881            wire_version: 2,
5882            msg_kind: MsgKind::Response,
5883            frames: vec![
5884                Frame::RespHeader {
5885                    required_schema_version: None,
5886                    latest_schema_version: None,
5887                    log_epoch: Some("epoch-1".into()),
5888                    reset_required: Some(false),
5889                },
5890                Frame::PushResult {
5891                    client_commit_id: id.clone(),
5892                    status: PushStatus::Applied,
5893                    commit_seq: Some(2),
5894                    results: vec![OpResult::Applied { op_index: 0 }],
5895                },
5896            ],
5897        };
5898        let (_, meta) = client.build_request(false).unwrap();
5899        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5900        assert!(
5901            matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
5902        );
5903        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5904        assert!(client.commit_outcome(&id).unwrap().is_none());
5905        assert_eq!(
5906            client
5907                .conn
5908                .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
5909                    .get::<_, i64>(
5910                    0
5911                ))
5912                .unwrap(),
5913            0
5914        );
5915        assert_eq!(
5916            client
5917                .conn
5918                .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
5919                    row.get::<_, i64>(0)
5920                })
5921                .unwrap(),
5922            0
5923        );
5924        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5925        assert_eq!(client.local_revision(), revision);
5926        assert!(client.drain_change_batches().is_empty());
5927        client
5928            .conn
5929            .execute_batch("DROP TRIGGER fail_replay")
5930            .unwrap();
5931        assert!(matches!(
5932            client.process_response(&mut transport, response, &meta),
5933            SyncOutcome::Ok(_)
5934        ));
5935        client
5936            .apply_commit_frame(&["tasks".into()], &[change], Some(2))
5937            .unwrap();
5938        assert!(client.pending_commit_ids().is_empty());
5939        assert_eq!(
5940            client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
5941            json!("remote")
5942        );
5943    }
5944
5945    #[test]
5946    fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
5947        let client = SyncClient::new(
5948            "replay".into(),
5949            &replay_schema(false),
5950            ClientLimits::default(),
5951        )
5952        .unwrap();
5953        let table = client.schema.table("tasks").unwrap();
5954        let patch = OutboxOp {
5955            table: "tasks".into(),
5956            row_id: "absent".into(),
5957            upsert: true,
5958            base_version: Some(1),
5959            values: Some(Map::from_iter([
5960                ("id".into(), json!("absent")),
5961                ("title".into(), json!("patch")),
5962            ])),
5963        };
5964        assert!(client.visible_row(table, "absent").unwrap().is_none());
5965        client
5966            .apply_outbox_ops([&patch])
5967            .expect("sparse replay over truly absent row");
5968        client
5969            .conn
5970            .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
5971            .unwrap();
5972        assert!(client
5973            .visible_row(table, "bad")
5974            .unwrap_err()
5975            .starts_with("sync.local_corrupt:"));
5976        let mut invalid = patch.clone();
5977        invalid.values = Some(Map::from_iter([
5978            ("id".into(), json!("bad-value")),
5979            ("project_id".into(), json!("p1")),
5980            ("title".into(), json!("title")),
5981            ("done".into(), json!({"bad":true})),
5982        ]));
5983        assert!(client
5984            .apply_outbox_ops([&invalid])
5985            .unwrap_err()
5986            .starts_with("sync.local_corrupt:"));
5987        client.conn.execute_batch("DROP TABLE tasks").unwrap();
5988        assert!(
5989            client.apply_outbox_ops([&patch]).is_err(),
5990            "SQL failure must not count as absence"
5991        );
5992    }
5993
5994    #[test]
5995    fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
5996        let mut schema = replay_schema(false);
5997        schema["tables"][0]["indexes"] =
5998            json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
5999        let mut client =
6000            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
6001        let id = client
6002            .mutate(vec![Mutation::Upsert {
6003                table: "tasks".into(),
6004                values: Map::from_iter([
6005                    ("id".into(), json!("queued")),
6006                    ("project_id".into(), json!("p1")),
6007                    ("title".into(), json!("occupied")),
6008                    ("done".into(), json!(false)),
6009                ]),
6010                base_version: None,
6011            }])
6012            .unwrap();
6013        client
6014            .write_base_row(
6015                "tasks",
6016                &vec![
6017                    Some(ColumnValue::String("server".into())),
6018                    Some(ColumnValue::String("p1".into())),
6019                    Some(ColumnValue::String("occupied".into())),
6020                    Some(ColumnValue::Boolean(false)),
6021                ],
6022                1,
6023            )
6024            .unwrap();
6025        client.rebuild_overlay_if_dirty().unwrap();
6026        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6027        assert!(client
6028            .visible_row(client.schema.table("tasks").unwrap(), "queued")
6029            .unwrap()
6030            .is_none());
6031        assert!(client.rejections().is_empty());
6032        client.delete_base_row("tasks", "server").unwrap();
6033        client.rebuild_overlay_if_dirty().unwrap();
6034        assert!(client
6035            .visible_row(client.schema.table("tasks").unwrap(), "queued")
6036            .unwrap()
6037            .is_some());
6038        client
6039            .write_base_row(
6040                "tasks",
6041                &vec![
6042                    Some(ColumnValue::String("base".into())),
6043                    Some(ColumnValue::String("p1".into())),
6044                    Some(ColumnValue::String("base-title".into())),
6045                    Some(ColumnValue::Boolean(false)),
6046                ],
6047                1,
6048            )
6049            .unwrap();
6050        client.conn.execute_batch("DROP TABLE tasks").unwrap();
6051        client
6052            .retain_failed_commit(
6053                "retained",
6054                &[OutboxOp {
6055                    table: "tasks".into(),
6056                    row_id: "base".into(),
6057                    upsert: true,
6058                    base_version: Some(1),
6059                    values: Some(Map::from_iter([
6060                        ("id".into(), json!("base")),
6061                        ("title".into(), json!("patch")),
6062                    ])),
6063                }],
6064            )
6065            .expect("an existing base must not read the unavailable visible table");
6066    }
6067
6068    #[test]
6069    fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
6070        for failure in ["fts", "release"] {
6071            let mut client = SyncClient::new(
6072                "replay".into(),
6073                &replay_schema(true),
6074                ClientLimits::default(),
6075            )
6076            .unwrap();
6077            client
6078                .mutate(vec![Mutation::Upsert {
6079                    table: "tasks".into(),
6080                    values: Map::from_iter([
6081                        ("id".into(), json!("queued")),
6082                        ("project_id".into(), json!("p1")),
6083                        ("title".into(), json!("needle")),
6084                        ("done".into(), json!(false)),
6085                    ]),
6086                    base_version: None,
6087                }])
6088                .unwrap();
6089            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
6090            let fts_before = client
6091                .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6092                .unwrap();
6093            let triggers_before: Vec<String> = client
6094                .conn
6095                .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
6096                .unwrap()
6097                .query_map([], |row| row.get(0))
6098                .unwrap()
6099                .collect::<Result<_, _>>()
6100                .unwrap();
6101            if failure == "fts" {
6102                client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
6103            } else {
6104                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();
6105            }
6106            client.overlay_dirty.set(true);
6107            assert!(client.rebuild_overlay().is_err());
6108            assert!(client.overlay_dirty.get());
6109            assert!(client.conn.is_autocommit());
6110            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
6111            assert_eq!(
6112                client
6113                    .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6114                    .unwrap(),
6115                fts_before
6116            );
6117            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();
6118            assert_eq!(original_triggers, triggers_before);
6119            assert_eq!(client.pending_commit_ids().len(), 1);
6120            client
6121                .conn
6122                .execute_batch(if failure == "fts" {
6123                    "DROP TRIGGER fail_fts"
6124                } else {
6125                    "DROP TRIGGER fail_release"
6126                })
6127                .unwrap();
6128            client
6129                .rebuild_overlay()
6130                .expect("retry after removing the fault");
6131        }
6132    }
6133
6134    #[test]
6135    fn secondary_unique_collision_preserves_existing_synced_row() {
6136        let client = SyncClient::new(
6137            "unique-upsert-test".to_owned(),
6138            &json!({
6139                "version": 1,
6140                "tables": [{
6141                    "name": "tasks",
6142                    "primaryKey": "id",
6143                    "columns": [
6144                        { "name": "id", "type": "string", "nullable": false },
6145                        { "name": "project_id", "type": "string", "nullable": false },
6146                        { "name": "title", "type": "string", "nullable": false }
6147                    ],
6148                    "scopes": [{ "pattern": "project:{project_id}" }],
6149                    "indexes": [{
6150                        "name": "tasks_by_project_title",
6151                        "columns": ["project_id", "title"],
6152                        "unique": true
6153                    }]
6154                }]
6155            }),
6156            ClientLimits::default(),
6157        )
6158        .expect("test client");
6159        let table = client.schema.table("tasks").expect("tasks table");
6160        let sql = client.insert_row_sql(&base_table("tasks"), table);
6161
6162        client
6163            .conn
6164            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
6165            .expect("insert first row");
6166        client
6167            .conn
6168            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
6169            .expect("update same primary key");
6170        client
6171            .conn
6172            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
6173            .expect("insert second row");
6174        assert!(client
6175            .conn
6176            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
6177            .is_err());
6178
6179        let rows = client
6180            .conn
6181            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
6182            .expect("prepare rows")
6183            .query_map([], |row| {
6184                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6185            })
6186            .expect("query rows")
6187            .collect::<Result<Vec<_>, _>>()
6188            .expect("collect rows");
6189        assert_eq!(
6190            rows,
6191            vec![
6192                ("t1".to_owned(), "updated".to_owned()),
6193                ("t2".to_owned(), "original".to_owned())
6194            ]
6195        );
6196    }
6197
6198    #[test]
6199    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
6200        let path = std::env::temp_dir().join(format!(
6201            "syncular-startup-intent-{}.db",
6202            uuid::Uuid::new_v4()
6203        ));
6204        let schema = json!({
6205            "version": 1,
6206            "tables": [{
6207                "name": "tasks",
6208                "primaryKey": "id",
6209                "columns": [
6210                    { "name": "id", "type": "string", "nullable": false },
6211                    { "name": "project_id", "type": "string", "nullable": false }
6212                ],
6213                "scopes": [{ "pattern": "project:{project_id}" }]
6214            }]
6215        });
6216
6217        {
6218            let mut first = SyncClient::open_path_with_identity(
6219                None,
6220                &schema,
6221                ClientLimits::default(),
6222                path.to_str().expect("UTF-8 temp path"),
6223            )
6224            .expect("first open");
6225            first
6226                .set_window(
6227                    &WindowBase {
6228                        table: "tasks".to_owned(),
6229                        variable: "project_id".to_owned(),
6230                        fixed_scopes: Vec::new(),
6231                        params: None,
6232                    },
6233                    &["persisted".to_owned()],
6234                )
6235                .expect("persist window");
6236        }
6237
6238        let mut reopened = SyncClient::open_path_with_identity(
6239            None,
6240            &schema,
6241            ClientLimits::default(),
6242            path.to_str().expect("UTF-8 temp path"),
6243        )
6244        .expect("reopen");
6245        assert!(reopened.sync_needed());
6246        assert!(matches!(
6247            reopened.drain_sync_intents().as_slice(),
6248            [SyncIntent::Interactive]
6249        ));
6250        drop(reopened);
6251        std::fs::remove_file(path).expect("remove temp database");
6252    }
6253
6254    #[test]
6255    fn reopening_preserves_immutable_subscription_identity_and_progress() {
6256        let path = std::env::temp_dir().join(format!(
6257            "syncular-subscription-identity-{}.db",
6258            uuid::Uuid::new_v4()
6259        ));
6260        let schema = json!({
6261            "version": 1,
6262            "tables": [
6263                {
6264                    "name": "tasks",
6265                    "primaryKey": "id",
6266                    "columns": [
6267                        { "name": "id", "type": "string", "nullable": false },
6268                        { "name": "project_id", "type": "string", "nullable": false }
6269                    ],
6270                    "scopes": [{ "pattern": "project:{project_id}" }]
6271                },
6272                {
6273                    "name": "docs",
6274                    "primaryKey": "id",
6275                    "columns": [
6276                        { "name": "id", "type": "string", "nullable": false },
6277                        { "name": "org_id", "type": "string", "nullable": false },
6278                        { "name": "project_id", "type": "string", "nullable": false }
6279                    ],
6280                    "scopes": [
6281                        { "pattern": "org:{org_id}" },
6282                        { "pattern": "project:{projectId}", "column": "project_id" }
6283                    ]
6284                }
6285            ]
6286        });
6287
6288        {
6289            let mut first = SyncClient::open_path_with_identity(
6290                None,
6291                &schema,
6292                ClientLimits::default(),
6293                path.to_str().expect("UTF-8 temp path"),
6294            )
6295            .expect("first open");
6296            first
6297                .subscribe(
6298                    "stable-subscription".to_owned(),
6299                    "tasks".to_owned(),
6300                    vec![(
6301                        "project_id".to_owned(),
6302                        vec!["p2".to_owned(), "p1".to_owned()],
6303                    )],
6304                    Some(r#"{"view":"v1"}"#.to_owned()),
6305                )
6306                .expect("persist subscription");
6307            let persisted = {
6308                let subscription = first
6309                    .subs
6310                    .iter_mut()
6311                    .find(|subscription| subscription.id == "stable-subscription")
6312                    .expect("subscription");
6313                subscription.cursor = 41;
6314                subscription.bootstrap_state = Some("resume-token".to_owned());
6315                subscription.effective = Some(vec![(
6316                    "project_id".to_owned(),
6317                    vec!["p1".to_owned(), "p2".to_owned()],
6318                )]);
6319                subscription.synced_once = true;
6320                subscription.clone()
6321            };
6322            first.persist_sub(&persisted).unwrap();
6323        }
6324
6325        let mut reopened = SyncClient::open_path_with_identity(
6326            None,
6327            &schema,
6328            ClientLimits::default(),
6329            path.to_str().expect("UTF-8 temp path"),
6330        )
6331        .expect("reopen");
6332        let progress = reopened
6333            .subscription_state("stable-subscription")
6334            .expect("persisted state");
6335        assert_eq!(progress.cursor, 41);
6336        assert!(progress.has_resume_token);
6337
6338        reopened
6339            .subscribe(
6340                "stable-subscription".to_owned(),
6341                "tasks".to_owned(),
6342                vec![(
6343                    "project_id".to_owned(),
6344                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
6345                )],
6346                Some(r#"{"view":"v1"}"#.to_owned()),
6347            )
6348            .expect("canonical intent is idempotent");
6349        assert_eq!(
6350            reopened
6351                .subscription_state("stable-subscription")
6352                .expect("unchanged state")
6353                .cursor,
6354            progress.cursor
6355        );
6356
6357        for (table, scopes, params) in [
6358            (
6359                "tasks",
6360                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6361                Some(r#"{"view":"v1"}"#.to_owned()),
6362            ),
6363            (
6364                "tasks",
6365                vec![(
6366                    "project_id".to_owned(),
6367                    vec!["p2".to_owned(), "p1".to_owned()],
6368                )],
6369                Some(r#"{"view":"v2"}"#.to_owned()),
6370            ),
6371            (
6372                "docs",
6373                vec![
6374                    ("org_id".to_owned(), vec!["o1".to_owned()]),
6375                    ("projectId".to_owned(), vec!["p1".to_owned()]),
6376                ],
6377                Some(r#"{"view":"v1"}"#.to_owned()),
6378            ),
6379        ] {
6380            let error = reopened
6381                .subscribe(
6382                    "stable-subscription".to_owned(),
6383                    table.to_owned(),
6384                    scopes,
6385                    params,
6386                )
6387                .expect_err("identity rebind must fail");
6388            assert!(error.starts_with("client.subscription_intent_mismatch:"));
6389            assert_eq!(
6390                reopened
6391                    .subscription_state("stable-subscription")
6392                    .expect("unchanged state")
6393                    .cursor,
6394                progress.cursor
6395            );
6396        }
6397
6398        drop(reopened);
6399        std::fs::remove_file(path).expect("remove temp database");
6400    }
6401
6402    #[test]
6403    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
6404        let path = std::env::temp_dir().join(format!(
6405            "syncular-satisfied-schema-floor-{}.db",
6406            uuid::Uuid::new_v4()
6407        ));
6408        let schema = json!({
6409            "version": 23,
6410            "tables": [{
6411                "name": "tasks",
6412                "primaryKey": "id",
6413                "columns": [
6414                    { "name": "id", "type": "string", "nullable": false },
6415                    { "name": "project_id", "type": "string", "nullable": false }
6416                ],
6417                "scopes": [{ "pattern": "project:{project_id}" }]
6418            }]
6419        });
6420
6421        {
6422            let mut first = SyncClient::open_path_with_identity(
6423                None,
6424                &schema,
6425                ClientLimits::default(),
6426                path.to_str().expect("UTF-8 temp path"),
6427            )
6428            .expect("first open");
6429            first
6430                .subscribe(
6431                    "tasks".to_owned(),
6432                    "tasks".to_owned(),
6433                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6434                    None,
6435                )
6436                .expect("persist subscription");
6437            first.set_schema_floor(Some(SchemaFloor {
6438                required_schema_version: Some(22),
6439                latest_schema_version: Some(22),
6440            }));
6441        }
6442
6443        let mut reopened = SyncClient::open_path_with_identity(
6444            None,
6445            &schema,
6446            ClientLimits::default(),
6447            path.to_str().expect("UTF-8 temp path"),
6448        )
6449        .expect("reopen");
6450        assert!(reopened.schema_floor().is_none());
6451        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
6452        assert!(reopened.sync_needed());
6453        assert!(matches!(
6454            reopened.drain_sync_intents().as_slice(),
6455            [SyncIntent::Interactive]
6456        ));
6457        drop(reopened);
6458        std::fs::remove_file(path).expect("remove temp database");
6459    }
6460
6461    #[test]
6462    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
6463        let path = std::env::temp_dir().join(format!(
6464            "syncular-unsatisfied-schema-floor-{}.db",
6465            uuid::Uuid::new_v4()
6466        ));
6467        let schema = json!({
6468            "version": 1,
6469            "tables": [{
6470                "name": "tasks",
6471                "primaryKey": "id",
6472                "columns": [
6473                    { "name": "id", "type": "string", "nullable": false },
6474                    { "name": "project_id", "type": "string", "nullable": false }
6475                ],
6476                "scopes": [{ "pattern": "project:{project_id}" }]
6477            }]
6478        });
6479
6480        {
6481            let mut first = SyncClient::open_path_with_identity(
6482                None,
6483                &schema,
6484                ClientLimits::default(),
6485                path.to_str().expect("UTF-8 temp path"),
6486            )
6487            .expect("first open");
6488            first
6489                .subscribe(
6490                    "tasks".to_owned(),
6491                    "tasks".to_owned(),
6492                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6493                    None,
6494                )
6495                .expect("persist subscription");
6496            first.set_schema_floor(Some(SchemaFloor {
6497                required_schema_version: Some(2),
6498                latest_schema_version: Some(2),
6499            }));
6500        }
6501
6502        let reopened = SyncClient::open_path_with_identity(
6503            None,
6504            &schema,
6505            ClientLimits::default(),
6506            path.to_str().expect("UTF-8 temp path"),
6507        )
6508        .expect("reopen");
6509        assert_eq!(
6510            reopened.schema_floor(),
6511            Some(&SchemaFloor {
6512                required_schema_version: Some(2),
6513                latest_schema_version: Some(2),
6514            })
6515        );
6516        assert!(!reopened.sync_needed());
6517        drop(reopened);
6518        std::fs::remove_file(path).expect("remove temp database");
6519    }
6520
6521    #[test]
6522    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
6523        let path = std::env::temp_dir().join(format!(
6524            "syncular-indexed-column-bump-{}.db",
6525            uuid::Uuid::new_v4()
6526        ));
6527        let old_schema = json!({
6528            "version": 1,
6529            "tables": [
6530                {
6531                    "name": "tasks",
6532                    "primaryKey": "id",
6533                    "columns": [
6534                        { "name": "id", "type": "string", "nullable": false },
6535                        { "name": "project_id", "type": "string", "nullable": false }
6536                    ],
6537                    "scopes": [{ "pattern": "project:{project_id}" }]
6538                },
6539                {
6540                    "name": "legacy",
6541                    "primaryKey": "id",
6542                    "columns": [
6543                        { "name": "id", "type": "string", "nullable": false },
6544                        { "name": "project_id", "type": "string", "nullable": false }
6545                    ],
6546                    "scopes": [{ "pattern": "project:{project_id}" }]
6547                }
6548            ]
6549        });
6550        {
6551            let mut first = SyncClient::open_path_with_identity(
6552                None,
6553                &old_schema,
6554                ClientLimits::default(),
6555                path.to_str().expect("UTF-8 temp path"),
6556            )
6557            .expect("open old schema");
6558            first
6559                .subscribe(
6560                    "legacy-sub".to_owned(),
6561                    "legacy".to_owned(),
6562                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6563                    None,
6564                )
6565                .expect("persist legacy subscription");
6566        }
6567
6568        let new_schema = json!({
6569            "version": 2,
6570            "tables": [{
6571                "name": "tasks",
6572                "primaryKey": "id",
6573                "columns": [
6574                    { "name": "id", "type": "string", "nullable": false },
6575                    { "name": "project_id", "type": "string", "nullable": false },
6576                    { "name": "facility_membership_id", "type": "string", "nullable": true }
6577                ],
6578                "scopes": [{ "pattern": "project:{project_id}" }],
6579                "indexes": [{
6580                    "name": "tasks_by_membership",
6581                    "columns": ["project_id", "facility_membership_id"],
6582                    "unique": false
6583                }]
6584            }]
6585        });
6586        let upgraded = SyncClient::open_path_with_identity(
6587            None,
6588            &new_schema,
6589            ClientLimits::default(),
6590            path.to_str().expect("UTF-8 temp path"),
6591        )
6592        .expect("open upgraded schema");
6593        let columns = upgraded
6594            .conn
6595            .prepare("PRAGMA table_info(tasks)")
6596            .expect("prepare columns")
6597            .query_map([], |row| row.get::<_, String>(1))
6598            .expect("query columns")
6599            .collect::<Result<Vec<_>, _>>()
6600            .expect("collect columns");
6601        assert!(columns.contains(&"facility_membership_id".to_owned()));
6602        let index_count: i64 = upgraded
6603            .conn
6604            .query_row(
6605                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6606                [],
6607                |row| row.get(0),
6608            )
6609            .expect("query index");
6610        assert_eq!(index_count, 1);
6611        assert!(upgraded.subscription_state("legacy-sub").is_none());
6612        drop(upgraded);
6613        std::fs::remove_file(path).expect("remove temp database");
6614    }
6615
6616    #[test]
6617    fn migrates_pre_envelope_outcome_journal_additively() {
6618        let path = std::env::temp_dir().join(format!(
6619            "syncular-outcome-migration-{}.db",
6620            uuid::Uuid::new_v4()
6621        ));
6622        let conn = Connection::open(&path).expect("open legacy database");
6623        conn.execute_batch(
6624            "CREATE TABLE _syncular_commit_outcomes (
6625               seq INTEGER PRIMARY KEY AUTOINCREMENT,
6626               client_commit_id TEXT NOT NULL UNIQUE,
6627               status TEXT NOT NULL,
6628               recorded_at_ms INTEGER NOT NULL,
6629               results_json TEXT NOT NULL,
6630               resolution TEXT NOT NULL DEFAULT 'active',
6631               resolved_at_ms INTEGER,
6632               replacement_client_commit_id TEXT);",
6633        )
6634        .expect("create legacy outcome journal");
6635        drop(conn);
6636        let schema = json!({
6637            "version": 1,
6638            "tables": [{
6639                "name": "tasks",
6640                "primaryKey": "id",
6641                "columns": [
6642                    { "name": "id", "type": "string", "nullable": false },
6643                    { "name": "project_id", "type": "string", "nullable": false }
6644                ],
6645                "scopes": [{ "pattern": "project:{project_id}" }]
6646            }]
6647        });
6648        let client = SyncClient::open_path(
6649            "migration-native".to_owned(),
6650            &schema,
6651            ClientLimits::default(),
6652            path.to_str().expect("UTF-8 temp path"),
6653        )
6654        .expect("migrate database");
6655        let has_operations = client
6656            .conn
6657            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6658            .expect("prepare table info")
6659            .query_map([], |row| row.get::<_, String>(1))
6660            .expect("query table info")
6661            .filter_map(Result::ok)
6662            .any(|column| column == "operations_json");
6663        assert!(has_operations);
6664        drop(client);
6665        std::fs::remove_file(path).expect("remove temp database");
6666    }
6667
6668    #[test]
6669    fn retained_rejection_survives_file_reopen_and_security_purge() {
6670        let path =
6671            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6672        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6673            { "name": "id", "type": "string", "nullable": false },
6674            { "name": "project_id", "type": "string", "nullable": false },
6675            { "name": "title", "type": "string", "nullable": false }
6676        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6677        {
6678            let mut first = SyncClient::open_path(
6679                "retained-native".to_owned(),
6680                &schema,
6681                ClientLimits::default(),
6682                path.to_str().unwrap(),
6683            )
6684            .unwrap();
6685            first
6686                .write_base_row(
6687                    "tasks",
6688                    &vec![
6689                        Some(ColumnValue::String("t1".into())),
6690                        Some(ColumnValue::String("p1".into())),
6691                        Some(ColumnValue::String("server".into())),
6692                    ],
6693                    1,
6694                )
6695                .unwrap();
6696            first.rebuild_overlay().expect("rebuild overlay");
6697            let operation = OutboxOp {
6698                table: "tasks".into(),
6699                row_id: "t1".into(),
6700                upsert: true,
6701                base_version: Some(1),
6702                values: Some(Map::from_iter([
6703                    ("id".into(), json!("t1")),
6704                    ("title".into(), json!("mine")),
6705                ])),
6706            };
6707            first
6708                .retain_failed_commit("retained", std::slice::from_ref(&operation))
6709                .unwrap();
6710            first
6711                .persist_commit_outcome(
6712                    "retained",
6713                    CommitOutcomeStatus::Rejected,
6714                    &[CommitOperationOutcome::Error {
6715                        rejection: RejectionRecord {
6716                            client_commit_id: "retained".into(),
6717                            op_index: 0,
6718                            code: "validation.denied".into(),
6719                            message: "denied".into(),
6720                            retryable: false,
6721                            details: None,
6722                            operation: None,
6723                        },
6724                    }],
6725                    Some(&[operation]),
6726                )
6727                .unwrap();
6728            first.rebuild_overlay().expect("rebuild overlay");
6729        }
6730        {
6731            let mut reopened = SyncClient::open_path(
6732                "retained-native".to_owned(),
6733                &schema,
6734                ClientLimits::default(),
6735                path.to_str().unwrap(),
6736            )
6737            .unwrap();
6738            let row = reopened
6739                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6740                .expect("read visible row")
6741                .unwrap();
6742            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6743            assert_eq!(
6744                reopened
6745                    .commit_outcome("retained")
6746                    .unwrap()
6747                    .unwrap()
6748                    .retained_rows
6749                    .unwrap()[0]
6750                    .server_row
6751                    .as_ref()
6752                    .unwrap()["title"],
6753                json!("server")
6754            );
6755            reopened
6756                .conn
6757                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6758                .unwrap();
6759            reopened.rebuild_overlay().expect("rebuild overlay");
6760            assert!(reopened
6761                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6762                .expect("read visible row")
6763                .is_none());
6764            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6765            assert!(retained.retained_rows.is_some());
6766            assert_eq!(
6767                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6768                json!("mine")
6769            );
6770            reopened
6771                .purge_local_data(&LocalDataPurgeInput {
6772                    purge_id: "protected-purge".into(),
6773                    targets: vec![LocalDataPurgeTarget {
6774                        table: "tasks".into(),
6775                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6776                    }],
6777                })
6778                .unwrap();
6779            assert!(reopened
6780                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6781                .expect("read visible row")
6782                .is_none());
6783            assert!(reopened
6784                .commit_outcome("retained")
6785                .unwrap()
6786                .unwrap()
6787                .retained_rows
6788                .is_none());
6789        }
6790        std::fs::remove_file(path).unwrap();
6791    }
6792
6793    #[test]
6794    fn durable_conflict_outcome_and_resolution_survive_reopen() {
6795        let path = std::env::temp_dir().join(format!(
6796            "syncular-durable-outcome-{}.db",
6797            uuid::Uuid::new_v4()
6798        ));
6799        let schema = json!({
6800            "version": 1,
6801            "tables": [{
6802                "name": "tasks",
6803                "primaryKey": "id",
6804                "columns": [
6805                    { "name": "id", "type": "string", "nullable": false },
6806                    { "name": "project_id", "type": "string", "nullable": false }
6807                ],
6808                "scopes": [{ "pattern": "project:{project_id}" }]
6809            }]
6810        });
6811        let conflict = ConflictRecord {
6812            client_commit_id: "losing-commit".to_owned(),
6813            op_index: 0,
6814            table: "tasks".to_owned(),
6815            row_id: "t1".to_owned(),
6816            code: "sync.version_conflict".to_owned(),
6817            message: "stale base version".to_owned(),
6818            server_version: 2,
6819            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6820            conflict_columns: vec!["project_id".to_owned()],
6821            operation: Some(CommitOperation {
6822                table: "tasks".to_owned(),
6823                row_id: "t1".to_owned(),
6824                op: "upsert".to_owned(),
6825                base_version: Some(1),
6826                values: Some(Map::from_iter([
6827                    ("id".into(), json!("t1")),
6828                    ("project_id".into(), json!("p1")),
6829                ])),
6830            }),
6831        };
6832        let failed_operations = vec![
6833            OutboxOp {
6834                upsert: true,
6835                table: "tasks".to_owned(),
6836                row_id: "t1".to_owned(),
6837                base_version: Some(1),
6838                values: Some(Map::from_iter([
6839                    ("id".into(), json!("t1")),
6840                    ("project_id".into(), json!("p1")),
6841                ])),
6842            },
6843            OutboxOp {
6844                upsert: true,
6845                table: "tasks".to_owned(),
6846                row_id: "status-event-1".to_owned(),
6847                base_version: Some(0),
6848                values: Some(Map::from_iter([
6849                    ("id".into(), json!("status-event-1")),
6850                    ("project_id".into(), json!("p1")),
6851                ])),
6852            },
6853        ];
6854
6855        {
6856            let mut first = SyncClient::open_path(
6857                "durable-native".to_owned(),
6858                &schema,
6859                ClientLimits::default(),
6860                path.to_str().expect("UTF-8 temp path"),
6861            )
6862            .expect("first open");
6863            first
6864                .begin_observation("test_outcome")
6865                .expect("begin outcome");
6866            first
6867                .persist_commit_outcome(
6868                    "losing-commit",
6869                    CommitOutcomeStatus::Conflict,
6870                    &[CommitOperationOutcome::Conflict {
6871                        conflict: conflict.clone(),
6872                    }],
6873                    Some(&failed_operations),
6874                )
6875                .expect("persist outcome");
6876            first.conflicts.push(conflict);
6877            first
6878                .finish_observation(
6879                    "test_outcome",
6880                    ChangeAccumulator {
6881                        conflicts: true,
6882                        outcomes: true,
6883                        ..ChangeAccumulator::default()
6884                    },
6885                )
6886                .expect("commit outcome");
6887        }
6888
6889        {
6890            let mut reopened = SyncClient::open_path(
6891                "durable-native".to_owned(),
6892                &schema,
6893                ClientLimits::default(),
6894                path.to_str().expect("UTF-8 temp path"),
6895            )
6896            .expect("reopen");
6897            assert_eq!(reopened.conflicts().len(), 1);
6898            assert_eq!(
6899                reopened.conflicts()[0].conflict_columns,
6900                vec!["project_id".to_owned()],
6901                "conflictColumns survives the journal round trip"
6902            );
6903            let outcome = reopened
6904                .commit_outcome("losing-commit")
6905                .expect("read outcome")
6906                .expect("outcome");
6907            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6908            let operations = outcome.operations.expect("aggregate envelope");
6909            assert_eq!(operations.len(), 2);
6910            assert_eq!(operations[1].row_id, "status-event-1");
6911            let resolved = reopened
6912                .resolve_commit_outcome(ResolveCommitOutcomeInput {
6913                    client_commit_id: "losing-commit".to_owned(),
6914                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
6915                    replacement_client_commit_id: None,
6916                })
6917                .expect("resolve");
6918            assert_eq!(
6919                resolved.resolution,
6920                CommitOutcomeResolution::ResolvedKeepServer
6921            );
6922            assert!(reopened.conflicts().is_empty());
6923        }
6924
6925        let reopened = SyncClient::open_path(
6926            "durable-native".to_owned(),
6927            &schema,
6928            ClientLimits::default(),
6929            path.to_str().expect("UTF-8 temp path"),
6930        )
6931        .expect("second reopen");
6932        assert!(reopened.conflicts().is_empty());
6933        assert_eq!(
6934            reopened
6935                .commit_outcome("losing-commit")
6936                .expect("read outcome")
6937                .expect("outcome")
6938                .resolution,
6939            CommitOutcomeResolution::ResolvedKeepServer
6940        );
6941        drop(reopened);
6942        std::fs::remove_file(path).expect("remove temp database");
6943    }
6944
6945    #[test]
6946    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
6947        let path =
6948            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6949        let schema = json!({
6950            "version": 1,
6951            "tables": [{
6952                "name": "tasks",
6953                "primaryKey": "id",
6954                "columns": [
6955                    { "name": "id", "type": "string", "nullable": false },
6956                    { "name": "project_id", "type": "string", "nullable": false }
6957                ],
6958                "scopes": [{ "pattern": "project:{project_id}" }]
6959            }]
6960        });
6961        let mut client = SyncClient::open_path_with_identity(
6962            Some("sidecar-client".to_owned()),
6963            &schema,
6964            ClientLimits::default(),
6965            path.to_str().expect("UTF-8 temp path"),
6966        )
6967        .expect("open owner");
6968        let base = WindowBase {
6969            table: "tasks".to_owned(),
6970            variable: "project_id".to_owned(),
6971            fixed_scopes: Vec::new(),
6972            params: None,
6973        };
6974        client
6975            .set_window(&base, &["one".to_owned()])
6976            .expect("set window");
6977        client
6978            .mutate(vec![Mutation::Upsert {
6979                table: "tasks".to_owned(),
6980                values: Map::from_iter([
6981                    ("id".to_owned(), Value::from("t1")),
6982                    ("project_id".to_owned(), Value::from("one")),
6983                ]),
6984                base_version: None,
6985            }])
6986            .expect("local mutate");
6987
6988        let coverage = [WindowCoverage {
6989            base,
6990            units: vec!["one".to_owned(), "missing".to_owned()],
6991        }];
6992        let owner = client
6993            .query_snapshot(
6994                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6995                &[],
6996                &coverage,
6997                None,
6998            )
6999            .expect("owner snapshot");
7000        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7001        let sidecar = reader
7002            .query_snapshot(
7003                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7004                &[],
7005                &coverage,
7006            )
7007            .expect("sidecar snapshot");
7008
7009        assert_eq!(sidecar.revision, owner.revision);
7010        assert_eq!(sidecar.rows, owner.rows);
7011        assert_eq!(
7012            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
7013            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
7014        );
7015        assert_eq!(sidecar.revision, "2");
7016        assert_eq!(sidecar.rows[0]["id"], "t1");
7017        assert_eq!(sidecar.rows[0]["server_version"], -1);
7018        assert!(!sidecar.coverage.complete);
7019        assert_eq!(sidecar.coverage.pending.len(), 1);
7020        assert_eq!(sidecar.coverage.missing.len(), 1);
7021
7022        drop(reader);
7023        drop(client);
7024        std::fs::remove_file(path).expect("remove temp database");
7025    }
7026
7027    #[test]
7028    fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
7029        let path =
7030            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7031        let schema = json!({
7032            "version": 1,
7033            "tables": [{
7034                "name": "tasks",
7035                "primaryKey": "id",
7036                "columns": [
7037                    { "name": "id", "type": "string", "nullable": false },
7038                    { "name": "project_id", "type": "string", "nullable": false }
7039                ],
7040                "scopes": [{ "pattern": "project:{project_id}" }]
7041            }]
7042        });
7043        let mut client = SyncClient::open_path_with_identity(
7044            Some("sidecar-catchup".to_owned()),
7045            &schema,
7046            ClientLimits::default(),
7047            path.to_str().expect("UTF-8 temp path"),
7048        )
7049        .expect("open owner");
7050        client
7051            .subscribe(
7052                "s1".into(),
7053                "tasks".into(),
7054                vec![("project_id".into(), vec!["one".into()])],
7055                None,
7056            )
7057            .expect("subscribe");
7058        let commit_id = client
7059            .mutate(vec![Mutation::Upsert {
7060                table: "tasks".to_owned(),
7061                values: Map::from_iter([
7062                    ("id".to_owned(), Value::from("t1")),
7063                    ("project_id".to_owned(), Value::from("one")),
7064                ]),
7065                base_version: None,
7066            }])
7067            .expect("mutate");
7068        let revision = client.local_revision().to_string();
7069
7070        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7071        assert_eq!(reader.local_revision().expect("revision"), revision);
7072
7073        let read = reader
7074            .snapshot_read(&SnapshotReadRequest {
7075                statements: vec![
7076                    SnapshotStatement {
7077                        sql: "SELECT 1 AS n",
7078                        params: &[],
7079                    },
7080                    SnapshotStatement {
7081                        sql: "SELECT id FROM tasks ORDER BY id",
7082                        params: &[],
7083                    },
7084                ],
7085                coverage: &[],
7086                subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
7087                commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
7088            })
7089            .expect("snapshot read");
7090        assert_eq!(read.revision, revision);
7091        assert_eq!(read.queries.len(), 2);
7092        assert_eq!(read.queries[0][0]["n"], 1);
7093        assert_eq!(read.queries[1][0]["id"], "t1");
7094        match &read.subscriptions[0] {
7095            SubscriptionCatchup::Known {
7096                status,
7097                cursor,
7098                bootstrap_complete,
7099                known_pending_pages,
7100                ..
7101            } => {
7102                assert_eq!(status, "active");
7103                assert_eq!(*cursor, -1);
7104                assert!(!bootstrap_complete);
7105                assert!(known_pending_pages);
7106            }
7107            other => panic!("expected a known subscription: {other:?}"),
7108        }
7109        assert!(matches!(
7110            &read.subscriptions[1],
7111            SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
7112        ));
7113        assert!(matches!(
7114            &read.deliveries[0],
7115            CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
7116        ));
7117        assert!(matches!(
7118            &read.deliveries[1],
7119            CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
7120        ));
7121
7122        // A bad statement rolls the whole read back and releases the connection.
7123        assert!(reader
7124            .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
7125            .is_err());
7126        assert_eq!(
7127            reader.local_revision().expect("revision after rollback"),
7128            revision
7129        );
7130        // The read-only guard refuses a write on any statement.
7131        assert!(reader
7132            .query_snapshot("DELETE FROM tasks", &[], &[])
7133            .is_err());
7134
7135        // A corrupt revision marker fails typed, not as an invented zero.
7136        client
7137            .conn
7138            .execute(
7139                "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
7140                [],
7141            )
7142            .expect("corrupt revision");
7143        let failure = reader.local_revision().expect_err("corrupt revision fails");
7144        assert_eq!(failure.code, Some("sync.local_corrupt"));
7145
7146        drop(reader);
7147        drop(client);
7148        std::fs::remove_file(path).expect("remove temp database");
7149    }
7150
7151    #[test]
7152    fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
7153        let path =
7154            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7155        let schema = json!({
7156            "version": 1,
7157            "tables": [{
7158                "name": "tasks",
7159                "primaryKey": "id",
7160                "columns": [
7161                    { "name": "id", "type": "string", "nullable": false },
7162                    { "name": "project_id", "type": "string", "nullable": false }
7163                ],
7164                "scopes": [{ "pattern": "project:{project_id}" }]
7165            }]
7166        });
7167        let mut client = SyncClient::open_path_with_identity(
7168            Some("sidecar-delivery".to_owned()),
7169            &schema,
7170            ClientLimits::default(),
7171            path.to_str().expect("UTF-8 temp path"),
7172        )
7173        .expect("open owner");
7174        client.begin_observation("test_delivery").expect("observe");
7175        let operations = [OutboxOp {
7176            upsert: true,
7177            table: "tasks".to_owned(),
7178            row_id: "t1".to_owned(),
7179            base_version: Some(1),
7180            values: Some(Map::from_iter([
7181                ("id".into(), json!("t1")),
7182                ("project_id".into(), json!("p1")),
7183            ])),
7184        }];
7185        client
7186            .persist_commit_outcome(
7187                "conflict-commit",
7188                CommitOutcomeStatus::Conflict,
7189                &[CommitOperationOutcome::Conflict {
7190                    conflict: ConflictRecord {
7191                        client_commit_id: "conflict-commit".to_owned(),
7192                        op_index: 0,
7193                        table: "tasks".to_owned(),
7194                        row_id: "t1".to_owned(),
7195                        code: "sync.version_conflict".to_owned(),
7196                        message: "stale base version".to_owned(),
7197                        server_version: 2,
7198                        server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
7199                        conflict_columns: vec!["project_id".to_owned()],
7200                        operation: None,
7201                    },
7202                }],
7203                Some(&operations),
7204            )
7205            .expect("persist conflict");
7206        client
7207            .persist_commit_outcome(
7208                "rejected-commit",
7209                CommitOutcomeStatus::Rejected,
7210                &[CommitOperationOutcome::Error {
7211                    rejection: RejectionRecord {
7212                        client_commit_id: "rejected-commit".to_owned(),
7213                        op_index: 0,
7214                        code: "sync.unknown_table".to_owned(),
7215                        message: "unknown table".to_owned(),
7216                        retryable: false,
7217                        details: None,
7218                        operation: None,
7219                    },
7220                }],
7221                Some(&operations),
7222            )
7223            .expect("persist rejection");
7224        client
7225            .finish_observation(
7226                "test_delivery",
7227                ChangeAccumulator {
7228                    conflicts: true,
7229                    outcomes: true,
7230                    ..ChangeAccumulator::default()
7231                },
7232            )
7233            .expect("commit outcomes");
7234
7235        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7236        match reader.commit_delivery("conflict-commit").expect("conflict") {
7237            CommitDelivery::Known { outcome, .. } => {
7238                assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7239                assert!(matches!(
7240                    outcome.results.as_slice(),
7241                    [CommitOperationOutcome::Conflict { conflict }]
7242                        if conflict.code == "sync.version_conflict"
7243                            && conflict.server_version == 2
7244                ));
7245                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7246                assert!(
7247                    outcome.retained_rows.is_none(),
7248                    "the sidecar reports the persisted view, not owner-derived retained rows"
7249                );
7250            }
7251            other => panic!("expected a known conflict: {other:?}"),
7252        }
7253        match reader
7254            .commit_delivery("rejected-commit")
7255            .expect("rejection")
7256        {
7257            CommitDelivery::Known { outcome, .. } => {
7258                assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
7259                assert!(matches!(
7260                    outcome.results.as_slice(),
7261                    [CommitOperationOutcome::Error { rejection }]
7262                        if rejection.code == "sync.unknown_table"
7263                ));
7264                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7265                assert!(outcome.retained_rows.is_none());
7266            }
7267            other => panic!("expected a known rejection: {other:?}"),
7268        }
7269        drop(reader);
7270        drop(client);
7271        std::fs::remove_file(path).expect("remove temp database");
7272    }
7273
7274    #[test]
7275    fn snapshot_read_isolates_a_concurrent_writer_commit() {
7276        use std::sync::mpsc::channel;
7277        let path = std::env::temp_dir().join(format!(
7278            "syncular-read-concurrent-{}.db",
7279            uuid::Uuid::new_v4()
7280        ));
7281        let schema = json!({
7282            "version": 1,
7283            "tables": [{
7284                "name": "tasks",
7285                "primaryKey": "id",
7286                "columns": [
7287                    { "name": "id", "type": "string", "nullable": false },
7288                    { "name": "project_id", "type": "string", "nullable": false }
7289                ],
7290                "scopes": [{ "pattern": "project:{project_id}" }]
7291            }]
7292        });
7293        let mut client = SyncClient::open_path_with_identity(
7294            Some("sidecar-concurrent".to_owned()),
7295            &schema,
7296            ClientLimits::default(),
7297            path.to_str().expect("UTF-8 temp path"),
7298        )
7299        .expect("open owner");
7300        client
7301            .mutate(vec![Mutation::Upsert {
7302                table: "tasks".to_owned(),
7303                values: Map::from_iter([
7304                    ("id".to_owned(), Value::from("t1")),
7305                    ("project_id".to_owned(), Value::from("one")),
7306                ]),
7307                base_version: None,
7308            }])
7309            .expect("seed row");
7310        let revision_before = client.local_revision().to_string();
7311
7312        // A writer on a second connection commits while the reader is between
7313        // statements; the hook rendezvous is a barrier, not a sleep.
7314        let (ready_tx, ready_rx) = channel::<()>();
7315        let (done_tx, done_rx) = channel::<()>();
7316        let writer_path = path.to_string_lossy().into_owned();
7317        let writer_schema = schema.clone();
7318        let writer = std::thread::spawn(move || {
7319            let mut writer = SyncClient::open_path_with_identity(
7320                Some("sidecar-concurrent".to_owned()),
7321                &writer_schema,
7322                ClientLimits::default(),
7323                &writer_path,
7324            )
7325            .expect("open writer");
7326            ready_rx.recv().expect("reader signalled");
7327            writer
7328                .mutate(vec![Mutation::Upsert {
7329                    table: "tasks".to_owned(),
7330                    values: Map::from_iter([
7331                        ("id".to_owned(), Value::from("t2")),
7332                        ("project_id".to_owned(), Value::from("one")),
7333                    ]),
7334                    base_version: None,
7335                }])
7336                .expect("writer commit");
7337            let _ = done_tx.send(());
7338        });
7339        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7340        SNAPSHOT_READ_HOOK.with(|slot| {
7341            *slot.borrow_mut() = Some(Box::new(move || {
7342                ready_tx.send(()).expect("signal writer");
7343                done_rx.recv().expect("writer finished");
7344            }));
7345        });
7346        let read = reader
7347            .snapshot_read(&SnapshotReadRequest {
7348                statements: vec![
7349                    SnapshotStatement {
7350                        sql: "SELECT count(*) AS n FROM tasks",
7351                        params: &[],
7352                    },
7353                    SnapshotStatement {
7354                        sql: "SELECT count(*) AS n FROM tasks",
7355                        params: &[],
7356                    },
7357                ],
7358                coverage: &[],
7359                subscriptions: Vec::new(),
7360                commit_ids: Vec::new(),
7361            })
7362            .expect("snapshot read");
7363        assert_eq!(
7364            read.queries[0][0]["n"], 1,
7365            "first statement sees the pre-commit row"
7366        );
7367        assert_eq!(
7368            read.queries[1][0]["n"], 1,
7369            "a later statement keeps the one read snapshot"
7370        );
7371        assert_eq!(
7372            read.revision, revision_before,
7373            "the read reports the snapshot revision, not the writer's"
7374        );
7375        writer.join().expect("join writer");
7376
7377        assert_ne!(
7378            reader.local_revision().expect("revision after commit"),
7379            revision_before,
7380            "the next read observes the committed revision"
7381        );
7382        let after = reader
7383            .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
7384            .expect("count after commit");
7385        assert_eq!(
7386            after.rows[0]["n"], 2,
7387            "the committed row is visible afterwards"
7388        );
7389
7390        drop(reader);
7391        drop(client);
7392        std::fs::remove_file(path).expect("remove temp database");
7393    }
7394
7395    #[test]
7396    fn snapshot_read_rejects_every_non_canonical_revision() {
7397        let path = std::env::temp_dir().join(format!(
7398            "syncular-read-revision-{}.db",
7399            uuid::Uuid::new_v4()
7400        ));
7401        let schema = json!({
7402            "version": 1,
7403            "tables": [{
7404                "name": "tasks",
7405                "primaryKey": "id",
7406                "columns": [
7407                    { "name": "id", "type": "string", "nullable": false },
7408                    { "name": "project_id", "type": "string", "nullable": false }
7409                ],
7410                "scopes": [{ "pattern": "project:{project_id}" }]
7411            }]
7412        });
7413        let client = SyncClient::open_path_with_identity(
7414            Some("sidecar-revision".to_owned()),
7415            &schema,
7416            ClientLimits::default(),
7417            path.to_str().expect("UTF-8 temp path"),
7418        )
7419        .expect("open owner");
7420        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7421        for bad in [
7422            "+1",
7423            "01",
7424            "-1",
7425            " 1",
7426            "1 ",
7427            "18446744073709551616",
7428            "1.0",
7429            "abc",
7430            "",
7431        ] {
7432            client
7433                .conn
7434                .execute(
7435                    "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
7436                    rusqlite::params![bad],
7437                )
7438                .expect("corrupt revision");
7439            let failure = reader.local_revision().expect_err("corrupt marker fails");
7440            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
7441        }
7442        for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
7443            client
7444                .conn
7445                .execute_batch(&format!(
7446                    "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
7447                ))
7448                .expect("corrupt stored revision type");
7449            assert_eq!(
7450                reader.local_revision().expect_err("invalid SQL field").code,
7451                Some("sync.local_corrupt")
7452            );
7453        }
7454        // The absent marker is the legacy zero, not corruption.
7455        client
7456            .conn
7457            .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
7458            .expect("delete marker");
7459        assert_eq!(reader.local_revision().expect("legacy zero"), "0");
7460
7461        drop(reader);
7462        drop(client);
7463        std::fs::remove_file(path).expect("remove temp database");
7464    }
7465
7466    #[test]
7467    fn snapshot_read_rejects_corrupt_subscription_state() {
7468        let path = std::env::temp_dir().join(format!(
7469            "syncular-read-substate-{}.db",
7470            uuid::Uuid::new_v4()
7471        ));
7472        let schema = json!({
7473            "version": 1,
7474            "tables": [{
7475                "name": "tasks",
7476                "primaryKey": "id",
7477                "columns": [
7478                    { "name": "id", "type": "string", "nullable": false },
7479                    { "name": "project_id", "type": "string", "nullable": false }
7480                ],
7481                "scopes": [{ "pattern": "project:{project_id}" }]
7482            }]
7483        });
7484        let mut client = SyncClient::open_path_with_identity(
7485            Some("sidecar-substate".to_owned()),
7486            &schema,
7487            ClientLimits::default(),
7488            path.to_str().expect("UTF-8 temp path"),
7489        )
7490        .expect("open owner");
7491        client
7492            .subscribe(
7493                "s1".into(),
7494                "tasks".into(),
7495                vec![("project_id".into(), vec!["one".into()])],
7496                None,
7497            )
7498            .expect("subscribe");
7499        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7500        for bad in [
7501            "not json".to_owned(),
7502            "{}".to_owned(),
7503            json!({ "status": "bogus", "cursor": 0 }).to_string(),
7504            json!({ "status": "active", "cursor": "x" }).to_string(),
7505            json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
7506            json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
7507        ] {
7508            client
7509                .conn
7510                .execute(
7511                    "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
7512                    rusqlite::params![bad],
7513                )
7514                .expect("corrupt subscription state");
7515            let failure = reader
7516                .subscription_catchup("s1")
7517                .expect_err("corrupt state fails");
7518            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
7519        }
7520
7521        for assignment in [
7522            "tbl=X'7461736b73'",
7523            "state_json=X'7b7d'",
7524            "state_json=CAST(X'FF' AS TEXT)",
7525        ] {
7526            client.conn.execute_batch(&format!(
7527                "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
7528            )).expect("corrupt stored subscription type");
7529            assert_eq!(
7530                reader
7531                    .subscription_catchup("s1")
7532                    .expect_err("invalid SQL field")
7533                    .code,
7534                Some("sync.local_corrupt")
7535            );
7536        }
7537
7538        drop(reader);
7539        drop(client);
7540        std::fs::remove_file(path).expect("remove temp database");
7541    }
7542
7543    #[test]
7544    fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
7545        let path = std::env::temp_dir().join(format!(
7546            "syncular-read-rollback-{}.db",
7547            uuid::Uuid::new_v4()
7548        ));
7549        let schema = json!({
7550            "version": 1,
7551            "tables": [{
7552                "name": "tasks",
7553                "primaryKey": "id",
7554                "columns": [
7555                    { "name": "id", "type": "string", "nullable": false },
7556                    { "name": "project_id", "type": "string", "nullable": false }
7557                ],
7558                "scopes": [{ "pattern": "project:{project_id}" }]
7559            }]
7560        });
7561        let mut client = SyncClient::open_path_with_identity(
7562            Some("sidecar-rollback".to_owned()),
7563            &schema,
7564            ClientLimits::default(),
7565            path.to_str().expect("UTF-8 temp path"),
7566        )
7567        .expect("open owner");
7568        let commit_id = client
7569            .mutate(vec![Mutation::Upsert {
7570                table: "tasks".to_owned(),
7571                values: Map::from_iter([
7572                    ("id".to_owned(), Value::from("t1")),
7573                    ("project_id".to_owned(), Value::from("one")),
7574                ]),
7575                base_version: None,
7576            }])
7577            .expect("seed row");
7578        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7579        let revision = reader.local_revision().expect("revision");
7580
7581        // The second statement fails: the whole read rolls back and the
7582        // connection releases, so the same reader still works afterwards.
7583        let failure = reader
7584            .snapshot_read(&SnapshotReadRequest {
7585                statements: vec![
7586                    SnapshotStatement {
7587                        sql: "SELECT id FROM tasks",
7588                        params: &[],
7589                    },
7590                    SnapshotStatement {
7591                        sql: "SELECT * FROM does_not_exist",
7592                        params: &[],
7593                    },
7594                ],
7595                coverage: &[],
7596                subscriptions: Vec::new(),
7597                commit_ids: vec![commit_id.clone()],
7598            })
7599            .expect_err("second statement fails");
7600        assert_eq!(failure.code, None);
7601        assert_eq!(
7602            reader.local_revision().expect("revision after rollback"),
7603            revision,
7604            "a rolled-back read consumes no revision"
7605        );
7606        assert!(
7607            !matches!(
7608                reader
7609                    .commit_delivery(&commit_id)
7610                    .expect("delivery after rollback"),
7611                CommitDelivery::Unknown { .. }
7612            ),
7613            "the same reader serves the next read after releasing the connection"
7614        );
7615
7616        // The read-only guard rejects a write in the first position too.
7617        let guard = reader
7618            .snapshot_read(&SnapshotReadRequest {
7619                statements: vec![SnapshotStatement {
7620                    sql: "DELETE FROM tasks",
7621                    params: &[],
7622                }],
7623                coverage: &[],
7624                subscriptions: Vec::new(),
7625                commit_ids: Vec::new(),
7626            })
7627            .expect_err("write guard");
7628        assert!(guard.message.contains("read-only"), "{}", guard.message);
7629
7630        drop(reader);
7631        drop(client);
7632        std::fs::remove_file(path).expect("remove temp database");
7633    }
7634
7635    #[test]
7636    fn owned_query_failures_classify_order_bound_and_clear() {
7637        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7638            rusqlite::ffi::Error::new(266),
7639            Some("private SQLite prose".to_owned()),
7640        ));
7641        assert_eq!(io.code, Some("client.storage_io"));
7642        assert_eq!(io.sqlite_code, Some(266));
7643        assert_eq!(io.message, "local SQLite storage I/O failed");
7644        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7645            rusqlite::ffi::Error::new(26),
7646            None,
7647        ));
7648        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7649        assert!(!io.retryable());
7650        assert!(!corrupt.retryable());
7651
7652        // SQLITE_BUSY (5) and SQLITE_LOCKED (6), including extended codes, are
7653        // transient lock contention: classified busy and retryable.
7654        for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7655            let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7656                rusqlite::ffi::Error::new(raw),
7657                Some("database is locked".to_owned()),
7658            ));
7659            assert_eq!(busy.code, Some("client.storage_busy"));
7660            assert_eq!(busy.sqlite_code, Some(extended));
7661            assert_eq!(busy.message, "local SQLite storage is busy");
7662            assert!(busy.retryable());
7663        }
7664        let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7665            rusqlite::ffi::Error::new(1),
7666            Some("generic SQLite prose".to_owned()),
7667        ));
7668        assert_eq!(unrelated.code, None);
7669        assert!(!unrelated.retryable());
7670
7671        let mut client = client();
7672        let generic = QueryReadFailure::from("private query prose".to_owned());
7673        client.record_query_read(
7674            QueryOwner {
7675                id: "first",
7676                tables: &["tasks", "tasks"],
7677            },
7678            Some(&generic),
7679        );
7680        client.record_query_read(
7681            QueryOwner {
7682                id: "second",
7683                tables: &["tasks"],
7684            },
7685            Some(&generic),
7686        );
7687        client.record_query_read(
7688            QueryOwner {
7689                id: "first",
7690                tables: &["docs"],
7691            },
7692            Some(&io),
7693        );
7694        let failures = &client
7695            .diagnostics_snapshot(&Default::default())
7696            .expect("diagnostics")
7697            .query_failures;
7698        assert_eq!(
7699            failures
7700                .iter()
7701                .map(|entry| entry.id.as_str())
7702                .collect::<Vec<_>>(),
7703            vec!["second", "first"]
7704        );
7705        assert_eq!(failures[1].tables, vec!["docs"]);
7706        assert_eq!(failures[1].code, "client.storage_io");
7707
7708        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7709            client.record_query_read(
7710                QueryOwner {
7711                    id: &format!("query-{index}"),
7712                    tables: &["tasks"],
7713                },
7714                Some(&generic),
7715            );
7716        }
7717        let failures = &client
7718            .diagnostics_snapshot(&Default::default())
7719            .expect("bounded diagnostics")
7720            .query_failures;
7721        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7722        assert_eq!(failures.last().expect("last failure").id, "query-256");
7723
7724        client
7725            .query_snapshot(
7726                "SELECT id FROM tasks",
7727                &[],
7728                &[],
7729                Some(QueryOwner {
7730                    id: "query-256",
7731                    tables: &["tasks"],
7732                }),
7733            )
7734            .expect("successful owned read");
7735        assert!(client
7736            .diagnostics_snapshot(&Default::default())
7737            .expect("cleared diagnostics")
7738            .query_failures
7739            .iter()
7740            .all(|entry| entry.id != "query-256"));
7741    }
7742
7743    #[test]
7744    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7745        let mut client = client();
7746        let image = Connection::open_in_memory().unwrap();
7747        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7748            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7749            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7750            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7751            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7752        let table = client.schema.table("tasks").unwrap().clone();
7753        let scopes = vec![("project_id".into(), vec!["p1".into()])];
7754        assert!(client
7755            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7756            .is_err());
7757        assert!(client.conn.is_autocommit());
7758        assert_eq!(
7759            client
7760                .query("SELECT count(*) AS n FROM tasks", &[])
7761                .unwrap()[0]["n"],
7762            1024
7763        );
7764        assert_eq!(client.drain_change_batches().len(), 1);
7765        image
7766            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7767            .unwrap();
7768        assert!(matches!(
7769            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7770            Ok(2050)
7771        ));
7772        assert_eq!(
7773            client
7774                .query("SELECT count(*) AS n FROM tasks", &[])
7775                .unwrap()[0]["n"],
7776            2050
7777        );
7778        assert_eq!(client.drain_change_batches().len(), 3);
7779    }
7780
7781    #[test]
7782    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7783        let mut client = client();
7784        let base = WindowBase {
7785            table: "tasks".into(),
7786            variable: "project_id".into(),
7787            fixed_scopes: Vec::new(),
7788            params: None,
7789        };
7790        client
7791            .set_window(&base, &["p1".into(), "p2".into()])
7792            .unwrap();
7793        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7794            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7795            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7796            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7797            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7798        client.drain_change_batches();
7799        assert!(client.set_window(&base, &["p2".into()]).is_err());
7800        assert!(client.conn.is_autocommit());
7801        assert_eq!(
7802            client
7803                .query("SELECT count(*) AS n FROM tasks", &[])
7804                .unwrap()[0]["n"],
7805            1027
7806        );
7807        assert_eq!(client.window_state(&base).units, vec!["p2"]);
7808        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7809        assert_eq!(client.drain_change_batches().len(), 1);
7810        client
7811            .conn
7812            .execute_batch("DROP TRIGGER interrupt_evict")
7813            .unwrap();
7814        client.drain_pending_evictions().unwrap();
7815        assert!(client.load_pending_evictions().unwrap().is_empty());
7816        assert_eq!(
7817            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7818            "held"
7819        );
7820        assert_eq!(client.drain_change_batches().len(), 2);
7821    }
7822
7823    #[test]
7824    fn eviction_marker_failures_roll_back_window_changes() {
7825        let mut client = client();
7826        let base = WindowBase {
7827            table: "tasks".into(),
7828            variable: "project_id".into(),
7829            fixed_scopes: Vec::new(),
7830            params: None,
7831        };
7832        client.set_window(&base, &["p1".into()]).unwrap();
7833        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7834            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7835            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7836            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7837        let revision = client.local_revision();
7838        assert!(client.set_window(&base, &[]).is_err());
7839        assert!(client.conn.is_autocommit());
7840        assert_eq!(client.local_revision(), revision);
7841        assert_eq!(
7842            client
7843                .query("SELECT count(*) AS n FROM tasks", &[])
7844                .unwrap()[0]["n"],
7845            2050
7846        );
7847        assert_eq!(client.window_state(&base).units, vec!["p1"]);
7848        client.conn.execute_batch("DROP TRIGGER fail_marker;
7849            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7850        assert!(client.set_window(&base, &[]).is_err());
7851        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
7852            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7853        assert!(client.drain_pending_evictions().is_err());
7854        assert!(client.conn.is_autocommit());
7855        assert_eq!(
7856            client
7857                .query("SELECT count(*) AS n FROM tasks", &[])
7858                .unwrap()[0]["n"],
7859            2
7860        );
7861        assert!(client.set_window(&base, &["p1".into()]).is_err());
7862        assert!(client.conn.is_autocommit());
7863        assert!(client.window_state(&base).units.is_empty());
7864        assert!(client.subs.is_empty());
7865        assert_eq!(
7866            client
7867                .query(
7868                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
7869                    &[]
7870                )
7871                .unwrap()[0]["n"],
7872            1
7873        );
7874        client
7875            .conn
7876            .execute_batch("DROP TRIGGER fail_marker")
7877            .unwrap();
7878        client.set_window(&base, &["p1".into()]).unwrap();
7879        assert_eq!(client.window_state(&base).units, vec!["p1"]);
7880    }
7881
7882    #[test]
7883    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
7884        let mut client = client();
7885        client
7886            .conn
7887            .execute(
7888                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
7889                [],
7890            )
7891            .unwrap();
7892        assert_eq!(
7893            client.drain_pending_evictions().unwrap_err(),
7894            "sync.local_corrupt: invalid pending eviction scopes"
7895        );
7896        assert!(client.conn.is_autocommit());
7897        assert!(client.enqueue_startup_sync_if_needed().is_err());
7898    }
7899
7900    #[test]
7901    fn image_chunks_reject_null_primary_keys() {
7902        let mut client = client();
7903        let image = Connection::open_in_memory().unwrap();
7904        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7905            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
7906            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
7907            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7908            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
7909        let table = client.schema.table("tasks").unwrap().clone();
7910        assert!(client
7911            .apply_sqlite_image(
7912                &image,
7913                &table,
7914                true,
7915                &[("project_id".into(), vec!["p1".into()])],
7916                1025,
7917                7,
7918                "digest"
7919            )
7920            .is_err());
7921        assert!(client.conn.is_autocommit());
7922        assert_eq!(
7923            client
7924                .query("SELECT count(*) AS n FROM tasks", &[])
7925                .unwrap()[0]["n"],
7926            0
7927        );
7928    }
7929
7930    #[test]
7931    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
7932        for images in [false, true] {
7933            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7934                "name":name,"primaryKey":"id","columns":[
7935                    {"name":"id","type":"string","nullable":false},
7936                    {"name":"project_id","type":"string","nullable":false},
7937                    {"name":"title","type":"string","nullable":false}],
7938                "scopes":[{"pattern":"project:{project_id}"}],
7939                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
7940                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7941            })))});
7942            let mut actual =
7943                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
7944            let mut reference =
7945                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
7946            let scopes = vec![("project_id".into(), vec!["p1".into()])];
7947            for client in [&mut actual, &mut reference] {
7948                client
7949                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
7950                    .unwrap();
7951                client.subs[0].effective = Some(scopes.clone());
7952                for (table, id, title) in [
7953                    ("tasks", "a", "original"),
7954                    ("tasks", "b", "occupied"),
7955                    ("tasks", "c", "deleted"),
7956                    ("notes", "n", "untouched"),
7957                ] {
7958                    client
7959                        .write_base_row(
7960                            table,
7961                            &vec![
7962                                Some(ColumnValue::String(id.into())),
7963                                Some(ColumnValue::String("p1".into())),
7964                                Some(ColumnValue::String(title.into())),
7965                            ],
7966                            1,
7967                        )
7968                        .unwrap();
7969                }
7970                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7971                client
7972                    .mutate(vec![
7973                        Mutation::Upsert {
7974                            table: "tasks".into(),
7975                            values: Map::from_iter([
7976                                ("id".into(), Value::from("a")),
7977                                ("project_id".into(), Value::from("p1")),
7978                                ("title".into(), Value::from("available")),
7979                            ]),
7980                            base_version: None,
7981                        },
7982                        Mutation::Delete {
7983                            table: "tasks".into(),
7984                            row_id: "c".into(),
7985                            base_version: None,
7986                        },
7987                        Mutation::Upsert {
7988                            table: "tasks".into(),
7989                            values: Map::from_iter([
7990                                ("id".into(), Value::from("local")),
7991                                ("project_id".into(), Value::from("p1")),
7992                                ("title".into(), Value::from("local value")),
7993                            ]),
7994                            base_version: None,
7995                        },
7996                        Mutation::Upsert {
7997                            table: "notes".into(),
7998                            values: Map::from_iter([
7999                                ("id".into(), Value::from("n")),
8000                                ("project_id".into(), Value::from("p1")),
8001                                ("title".into(), Value::from("pending note")),
8002                            ]),
8003                            base_version: None,
8004                        },
8005                    ])
8006                    .unwrap();
8007            }
8008            let pending = actual.pending_commit_ids();
8009            actual.overlay_rebuild_count.set(0);
8010            for (id, title) in [
8011                ("b", "available"),
8012                ("b", "occupied"),
8013                ("a", "server original"),
8014                ("d", "local value"),
8015                ("d", "released"),
8016                ("c", "redelivered"),
8017            ] {
8018                let table = actual.schema.table("tasks").unwrap().clone();
8019                let values = vec![
8020                    Some(ColumnValue::String(id.into())),
8021                    Some(ColumnValue::String("p1".into())),
8022                    Some(ColumnValue::String(title.into())),
8023                ];
8024                if images {
8025                    let image = Connection::open_in_memory().unwrap();
8026                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8027                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8028                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8029                    image
8030                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
8031                        .unwrap();
8032                    assert!(matches!(
8033                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
8034                        Ok(1)
8035                    ));
8036                } else {
8037                    let segment = RowsSegment {
8038                        table: "tasks".into(),
8039                        schema_version: 1,
8040                        columns: table.wire_columns.clone(),
8041                        blocks: vec![vec![ssp2::segment::SegmentRow {
8042                            server_version: 2,
8043                            values: values.clone(),
8044                        }]],
8045                    };
8046                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
8047                }
8048                reference.write_base_row("tasks", &values, 2).unwrap();
8049                reference.rebuild_overlay().expect("rebuild overlay");
8050                for table in ["tasks", "notes"] {
8051                    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")] {
8052                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
8053                    }
8054                }
8055                assert_eq!(actual.pending_commit_ids(), pending);
8056            }
8057            assert_eq!(
8058                actual.overlay_rebuild_count.get(),
8059                0,
8060                "each chunk must leave unrelated tables alone"
8061            );
8062            let before = actual
8063                .query("SELECT * FROM tasks ORDER BY id", &[])
8064                .unwrap();
8065            let fts_before = actual
8066                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8067                .unwrap();
8068            let revision = actual.local_revision();
8069            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
8070            let table = actual.schema.table("tasks").unwrap().clone();
8071            let segment = RowsSegment {
8072                table: "tasks".into(),
8073                schema_version: 1,
8074                columns: table.wire_columns.clone(),
8075                blocks: vec![vec![ssp2::segment::SegmentRow {
8076                    server_version: 3,
8077                    values: vec![
8078                        Some(ColumnValue::String("b".into())),
8079                        Some(ColumnValue::String("p1".into())),
8080                        Some(ColumnValue::String("fresh".into())),
8081                    ],
8082                }]],
8083            };
8084            assert!(actual.apply_segment(0, &segment, false).is_err());
8085            assert!(actual.conn.is_autocommit());
8086            assert_eq!(
8087                actual
8088                    .query("SELECT * FROM tasks ORDER BY id", &[])
8089                    .unwrap(),
8090                before
8091            );
8092            assert_eq!(
8093                actual
8094                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8095                    .unwrap(),
8096                fts_before
8097            );
8098            assert_eq!(actual.local_revision(), revision);
8099            assert_eq!(actual.pending_commit_ids(), pending);
8100        }
8101    }
8102
8103    #[test]
8104    fn import_reconciliation_preserves_typed_pending_primary_keys() {
8105        for (kind, a, b) in [
8106            ("string", json!("a"), json!("b")),
8107            ("integer", json!(1), json!(2)),
8108            ("boolean", json!(true), json!(false)),
8109            ("json", json!("1"), json!("2")),
8110        ] {
8111            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}]}]});
8112            for images in [false, true] {
8113                let mut client =
8114                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
8115                let table = client.schema.table("tasks").unwrap().clone();
8116                client
8117                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
8118                    .unwrap();
8119                for (id, title) in [(&a, "original"), (&b, "occupied")] {
8120                    client
8121                        .write_base_row(
8122                            "tasks",
8123                            &vec![
8124                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
8125                                Some(ColumnValue::String(title.into())),
8126                            ],
8127                            1,
8128                        )
8129                        .unwrap();
8130                }
8131                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8132                client
8133                    .mutate(vec![Mutation::Upsert {
8134                        table: "tasks".into(),
8135                        values: Map::from_iter([
8136                            ("id".into(), a.clone()),
8137                            ("title".into(), json!("available")),
8138                        ]),
8139                        base_version: None,
8140                    }])
8141                    .unwrap();
8142                client.overlay_rebuild_count.set(0);
8143                for (title, expected) in [("available", "original"), ("occupied", "available")] {
8144                    let values = vec![
8145                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
8146                        Some(ColumnValue::String(title.into())),
8147                    ];
8148                    if images {
8149                        let image = Connection::open_in_memory().unwrap();
8150                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8151                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8152                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8153                        image
8154                            .execute(
8155                                "INSERT INTO tasks VALUES (?1,?2,2)",
8156                                rusqlite::params![RowParam::Cell(&values[0]), title],
8157                            )
8158                            .unwrap();
8159                        assert!(
8160                            matches!(
8161                                client.apply_sqlite_image(
8162                                    &image,
8163                                    &table,
8164                                    false,
8165                                    &[],
8166                                    1,
8167                                    7,
8168                                    "digest"
8169                                ),
8170                                Ok(1)
8171                            ),
8172                            "{kind}, images={images}"
8173                        );
8174                    } else {
8175                        let segment = RowsSegment {
8176                            table: "tasks".into(),
8177                            schema_version: 1,
8178                            columns: table.wire_columns.clone(),
8179                            blocks: vec![vec![ssp2::segment::SegmentRow {
8180                                server_version: 2,
8181                                values,
8182                            }]],
8183                        };
8184                        assert!(
8185                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
8186                            "{kind}"
8187                        );
8188                    }
8189                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
8190                    let actual: String = client
8191                        .conn
8192                        .query_row(
8193                            "SELECT title FROM tasks WHERE id=?1",
8194                            [RowParam::Cell(&key)],
8195                            |row| row.get(0),
8196                        )
8197                        .unwrap();
8198                    assert_eq!(actual, expected, "{kind}, images={images}");
8199                }
8200                assert_eq!(client.overlay_rebuild_count.get(), 0);
8201            }
8202        }
8203    }
8204
8205    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
8206        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8207            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
8208            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
8209            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8210        })))});
8211        let mut client =
8212            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
8213        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();
8214        client.rebuild_overlay().expect("rebuild overlay");
8215        client
8216            .mutate(vec![Mutation::Upsert {
8217                table: "tasks".into(),
8218                values: Map::from_iter([
8219                    ("id".into(), json!("offline")),
8220                    ("project_id".into(), json!("p1")),
8221                    ("title".into(), json!("pending edit")),
8222                ]),
8223                base_version: None,
8224            }])
8225            .unwrap();
8226        let image = Connection::open_in_memory().unwrap();
8227        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8228            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
8229        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();
8230        image
8231            .execute(
8232                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
8233                [rows],
8234            )
8235            .unwrap();
8236        client.overlay_rebuild_count.set(0);
8237        let table = client.schema.table("tasks").unwrap().clone();
8238        (client, image, table)
8239    }
8240
8241    #[test]
8242    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
8243        let (mut client, image, table) = unique_import_fixture(1);
8244        image
8245            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
8246            .unwrap();
8247        client
8248            .conn
8249            .execute_batch("PRAGMA max_page_count = 1")
8250            .unwrap();
8251        let effective = [("project_id".into(), vec!["p1".into()])];
8252        assert!(client
8253            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
8254            .is_err());
8255        let failure = client.storage_failure.borrow().clone().unwrap();
8256        assert_eq!(failure.code, Some("client.storage_full"));
8257        assert_eq!(failure.sqlite_code, Some(13));
8258        assert!(!failure.message.contains("rollback"));
8259        assert!(client.conn.is_autocommit());
8260        client
8261            .conn
8262            .execute_batch("PRAGMA max_page_count = 1073741823")
8263            .unwrap();
8264        assert!(matches!(
8265            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
8266            Ok(1)
8267        ));
8268        assert!(client.conn.is_autocommit());
8269    }
8270
8271    #[test]
8272    fn authoring_storage_failures_are_structured_and_scoped() {
8273        let path = std::env::temp_dir().join(format!(
8274            "syncular-authoring-failure-{}.db",
8275            uuid::Uuid::new_v4()
8276        ));
8277        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8278            {"name":"id","type":"string","nullable":false},
8279            {"name":"project_id","type":"string","nullable":false},
8280            {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8281        let mut client = SyncClient::open_path(
8282            "authoring".into(),
8283            &schema,
8284            ClientLimits::default(),
8285            path.to_str().unwrap(),
8286        )
8287        .expect("open client");
8288        client.create_synced_tables().expect("create synced tables");
8289        // Return BUSY immediately rather than waiting out the file timeout.
8290        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8291
8292        let upsert = |id: &str| {
8293            vec![Mutation::Upsert {
8294                table: "tasks".into(),
8295                values: Map::from_iter([
8296                    ("id".into(), json!(id)),
8297                    ("project_id".into(), json!("p1")),
8298                    ("title".into(), json!("first")),
8299                ]),
8300                base_version: None,
8301            }]
8302        };
8303
8304        let locker = Connection::open(&path).expect("second connection");
8305        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8306        let busy = client.mutate(upsert("t1")).expect_err("busy write");
8307        assert_eq!(busy.code, "client.storage_busy");
8308        assert!(busy.retryable);
8309        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8310        assert!(client.pending_commit_ids().is_empty());
8311        assert_eq!(client.local_revision(), 0);
8312
8313        // A stale classified failure must not classify a later unrelated one.
8314        let invalid = client.mutate(Vec::new()).expect_err("empty commit");
8315        assert_eq!(invalid.code, "sync.invalid_request");
8316        assert_eq!(invalid.message, "the authoring request is invalid");
8317        assert!(
8318            invalid.details.as_ref().unwrap()["legacyCause"]
8319                .as_str()
8320                .unwrap()
8321                .contains("at least one operation"),
8322            "{invalid:?}"
8323        );
8324
8325        locker.execute_batch("ROLLBACK").unwrap();
8326        let id = client.mutate(upsert("t1")).expect("retry after busy");
8327        assert_eq!(client.pending_commit_ids(), vec![id]);
8328        assert_eq!(client.local_revision(), 1);
8329
8330        // A later storage failure reports its own code and details.
8331        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8332        let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
8333        assert_eq!(busy_again.code, "client.storage_busy");
8334        assert!(busy_again.retryable);
8335        locker.execute_batch("ROLLBACK").unwrap();
8336
8337        // Non-retryable classified failures keep their sqlite evidence too.
8338        let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
8339            rusqlite::ffi::Error::new(13),
8340            Some("database or disk is full".to_owned()),
8341        )));
8342        assert_eq!(full.code, "client.storage_full");
8343        assert!(!full.retryable);
8344        assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
8345
8346        drop(locker);
8347        drop(client);
8348        let _ = std::fs::remove_file(&path);
8349    }
8350
8351    #[test]
8352    fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
8353        let path = std::env::temp_dir().join(format!(
8354            "syncular-caller-values-{}.db",
8355            uuid::Uuid::new_v4()
8356        ));
8357        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8358            {"name":"id","type":"string","nullable":false},
8359            {"name":"project_id","type":"string","nullable":false},
8360            {"name":"title","type":"string","nullable":false},
8361            {"name":"priority","type":"integer","nullable":false},
8362            {"name":"payload","type":"bytes","nullable":false},
8363            {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8364        let mut client = SyncClient::open_path(
8365            "caller-values".into(),
8366            &schema,
8367            ClientLimits::default(),
8368            path.to_str().unwrap(),
8369        )
8370        .expect("open client");
8371        client.create_synced_tables().expect("create synced tables");
8372
8373        let values = |id: &str| {
8374            Map::from_iter([
8375                ("id".to_owned(), json!(id)),
8376                ("project_id".to_owned(), json!("p1")),
8377                ("title".to_owned(), json!("first")),
8378                ("priority".to_owned(), json!(1)),
8379                ("payload".to_owned(), json!({"$bytes": "0a"})),
8380            ])
8381        };
8382        let upsert = |values: Map<String, Value>| {
8383            vec![Mutation::Upsert {
8384                table: "tasks".into(),
8385                values,
8386                base_version: None,
8387            }]
8388        };
8389        let seed = client.mutate(upsert(values("t1"))).expect("seed row");
8390        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
8391
8392        // §6.1/§7.1: every caller value the authoring seam rejects reports the
8393        // same stable identity instead of the internal-failure default, keeps
8394        // the dynamic cause in `details.legacyCause`, and leaves the durable
8395        // outbox, the local revision and the projection untouched.
8396        for (label, override_value, cause) in [
8397            ("unknown column", ("nope", json!(1)), "unknown column"),
8398            (
8399                "internal sync column",
8400                ("_sync_version", json!(1)),
8401                "internal sync column",
8402            ),
8403            ("wrong value type", ("title", json!(7)), "expected a string"),
8404            (
8405                "malformed bytes envelope",
8406                ("payload", json!({"$bytes": "zz"})),
8407                "expected exactly",
8408            ),
8409            (
8410                "odd-length bytes envelope",
8411                ("payload", json!({"$bytes": "abc"})),
8412                "expected exactly",
8413            ),
8414            (
8415                "plus-sign bytes envelope",
8416                ("payload", json!({"$bytes": "+a"})),
8417                "expected exactly",
8418            ),
8419            (
8420                "bytes envelope with a second key",
8421                ("payload", json!({"$bytes": "0a", "extra": 1})),
8422                "expected exactly",
8423            ),
8424            (
8425                "no bytes envelope",
8426                ("payload", json!({"nested": true})),
8427                "expected exactly",
8428            ),
8429        ] {
8430            let mut full = values("t2");
8431            full.insert(override_value.0.to_owned(), override_value.1);
8432            let error = client.mutate(upsert(full)).expect_err(label);
8433            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8434            assert_eq!(error.message, "the authoring request is invalid", "{label}");
8435            assert!(!error.retryable, "{label}");
8436            let legacy = error.details.as_ref().unwrap()["legacyCause"]
8437                .as_str()
8438                .unwrap();
8439            assert!(
8440                legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
8441                "{label}: {legacy}"
8442            );
8443            assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
8444            assert_eq!(client.local_revision(), 1, "{label}");
8445            assert_eq!(
8446                client.query("SELECT * FROM tasks", &[]).unwrap(),
8447                rows,
8448                "{label}"
8449            );
8450        }
8451
8452        // A full-row upsert presents every column, so a missing required one is
8453        // the caller's failure too.
8454        let mut missing = values("t3");
8455        missing.remove("payload");
8456        let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
8457        assert_eq!(missing_error.code, "sync.invalid_request");
8458        assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
8459            .as_str()
8460            .unwrap()
8461            .contains("is not nullable"));
8462
8463        // A primary key the wire cannot render is rejected before any op is
8464        // recorded.
8465        let mut bad_row_id = values("t4");
8466        bad_row_id.insert("id".to_owned(), json!({"nested": true}));
8467        let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
8468        assert_eq!(row_id_error.code, "sync.invalid_request");
8469
8470        // The sparse route classifies the same value failures.
8471        for (label, partial, cause) in [
8472            (
8473                "patch wrong value type",
8474                ("priority", json!("high")),
8475                "expected an integer",
8476            ),
8477            ("patch unknown column", ("nope", json!(1)), "unknown column"),
8478        ] {
8479            let error = client
8480                .patch(
8481                    "tasks",
8482                    "t1",
8483                    Map::from_iter([(partial.0.to_owned(), partial.1)]),
8484                    None,
8485                )
8486                .expect_err(label);
8487            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8488            assert_eq!(error.message, "the authoring request is invalid", "{label}");
8489            assert!(
8490                error.details.as_ref().unwrap()["legacyCause"]
8491                    .as_str()
8492                    .unwrap()
8493                    .contains(cause),
8494                "{label}"
8495            );
8496        }
8497        assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
8498        assert_eq!(client.local_revision(), 1);
8499        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
8500
8501        // Nullable explicit null, an empty envelope, and uppercase digits stay
8502        // accepted; the stored payload is the decoded byte sequence.
8503        for (label, payload) in [
8504            ("explicit null", json!(null)),
8505            ("empty envelope", json!({"$bytes": ""})),
8506            ("uppercase envelope", json!({"$bytes": "0AFF"})),
8507        ] {
8508            let mut accepted = values("t-ok");
8509            accepted.insert("extra".to_owned(), payload);
8510            let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
8511                panic!("{label} must be accepted: {error:?}");
8512            });
8513            assert!(!encoded.is_empty(), "{label}");
8514        }
8515
8516        // Nothing is poisoned: the next valid authoring call succeeds.
8517        let next = client
8518            .mutate(upsert(values("t5")))
8519            .expect("valid after failures");
8520        assert_ne!(next, seed);
8521
8522        drop(client);
8523        let _ = std::fs::remove_file(&path);
8524    }
8525
8526    #[test]
8527    fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
8528        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8529            {"name":"id","type":"string","nullable":false},
8530            {"name":"project_id","type":"string","nullable":false},
8531            {"name":"payload","type":"bytes","nullable":true}],
8532            "scopes":[{"pattern":"project:{project_id}"}]}]});
8533        let open = |path: &std::path::Path| {
8534            SyncClient::open_path(
8535                "legacy-values".into(),
8536                &schema,
8537                ClientLimits::default(),
8538                path.to_str().unwrap(),
8539            )
8540            .expect("open client")
8541        };
8542        let row = |id: &str| {
8543            Map::from_iter([
8544                ("id".to_owned(), json!(id)),
8545                ("project_id".to_owned(), json!("p1")),
8546                ("payload".to_owned(), json!({"$bytes": "0a"})),
8547            ])
8548        };
8549        let upsert = |values| {
8550            vec![Mutation::Upsert {
8551                table: "tasks".into(),
8552                values,
8553                base_version: None,
8554            }]
8555        };
8556        for (label, stored_cell, representable) in [
8557            ("plus-sign envelope", json!({"$bytes": "+a"}), false),
8558            ("non-hex envelope", json!({"$bytes": "zz"}), false),
8559            ("odd-length envelope", json!({"$bytes": "abc"}), false),
8560            ("no envelope", json!({"nested": true}), false),
8561            // A wrong declared type and a stored null for a non-nullable column
8562            // are journalable as stored, so their rejection keeps the operation
8563            // envelope.
8564            ("wrong declared type", json!("high"), true),
8565            ("stored null for a non-nullable column", json!(null), true),
8566        ] {
8567            let path = std::env::temp_dir().join(format!(
8568                "syncular-legacy-values-{}-{}.db",
8569                label.replace(' ', "-"),
8570                uuid::Uuid::new_v4()
8571            ));
8572            let mut client = open(&path);
8573            client.create_synced_tables().expect("create synced tables");
8574            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8575            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8576            drop(client);
8577
8578            // The stored intent an earlier version accepted.
8579            let connection = Connection::open(&path).expect("raw connection");
8580            let (column, cell) = if stored_cell.is_null() {
8581                ("project_id", stored_cell.clone())
8582            } else {
8583                ("payload", stored_cell.clone())
8584            };
8585            let mut planted_values = json!({
8586                "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
8587            });
8588            planted_values[column] = cell;
8589            let planted = json!([{
8590                "op": "upsert",
8591                "table": "tasks",
8592                "rowId": "t1",
8593                "baseVersion": null,
8594                "values": planted_values
8595            }]);
8596            connection
8597                .execute(
8598                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8599                    rusqlite::params![planted.to_string(), stale],
8600                )
8601                .expect("plant legacy intent");
8602            drop(connection);
8603
8604            // Reopen: the startup reconciliation recovers the commit before any
8605            // replay, so the observable phase matches the browser core.
8606            let reopened = open(&path);
8607            assert_eq!(
8608                reopened.pending_commit_ids(),
8609                vec![successor.clone()],
8610                "{label}"
8611            );
8612            let rejection = reopened
8613                .rejections()
8614                .iter()
8615                .find(|rejection| rejection.client_commit_id == stale)
8616                .unwrap_or_else(|| panic!("{label}: rejection"));
8617            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8618            assert_eq!(
8619                rejection.message, "the persisted commit carries values the current codec refuses",
8620                "{label}"
8621            );
8622            assert_eq!(rejection.operation.is_some(), representable, "{label}");
8623            assert_eq!(
8624                rejection
8625                    .details
8626                    .as_ref()
8627                    .and_then(|details| details.reason.as_deref()),
8628                Some(INVALID_STORED_VALUES_REASON),
8629                "{label}"
8630            );
8631            // The rejection stays readable after a further reopen.
8632            let journal = reopened
8633                .commit_outcome(&stale)
8634                .expect("read outcome")
8635                .expect("journal entry");
8636            assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8637            drop(reopened);
8638            let mut reopened = open(&path);
8639            let reread = reopened
8640                .commit_outcome(&stale)
8641                .expect("read outcome")
8642                .expect("journal entry");
8643            assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8644
8645            // The legacy row's optimistic projection is gone, the successor
8646            // survives and still encodes, and the round path keeps the queue.
8647            assert_eq!(
8648                reopened
8649                    .query("SELECT id FROM tasks ORDER BY id", &[])
8650                    .unwrap(),
8651                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8652                "{label}"
8653            );
8654            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8655            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8656            assert!(!reopened
8657                .drop_unencodable_outbox(false, None)
8658                .expect("idempotent prepass"));
8659            let mut transport = CountingRealtimeTransport::default();
8660            let outcome = reopened.sync(&mut transport);
8661            assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8662            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8663            let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8664            assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8665            assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8666            drop(reopened);
8667            let _ = std::fs::remove_file(&path);
8668        }
8669    }
8670
8671    #[test]
8672    fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8673        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8674            {"name":"id","type":"string","nullable":false},
8675            {"name":"project_id","type":"string","nullable":false},
8676            {"name":"payload","type":"bytes","nullable":true}],
8677            "scopes":[{"pattern":"project:{project_id}"}]}]});
8678        for (label, column, planted) in [
8679            ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8680            (
8681                "stored null for a non-nullable column",
8682                "project_id",
8683                json!(null),
8684            ),
8685            ("wrong declared type", "payload", json!(7)),
8686        ] {
8687            let path = std::env::temp_dir().join(format!(
8688                "syncular-shared-row-{}-{}.db",
8689                label.replace(' ', "-"),
8690                uuid::Uuid::new_v4()
8691            ));
8692            let open = |path: &std::path::Path| {
8693                SyncClient::open_path(
8694                    "shared-row".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            let mut client = open(&path);
8716            client.create_synced_tables().expect("create synced tables");
8717            let older = client.mutate(upsert(row("t1"))).expect("older commit");
8718            let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8719            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8720            drop(client);
8721
8722            let connection = Connection::open(&path).expect("raw connection");
8723            for commit_id in [&older, &newer] {
8724                let mut values = row("t1");
8725                values.insert(column.to_owned(), planted.clone());
8726                let planted_ops = json!([{
8727                    "op": "upsert",
8728                    "table": "tasks",
8729                    "rowId": "t1",
8730                    "baseVersion": null,
8731                    "values": values
8732                }]);
8733                connection
8734                    .execute(
8735                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8736                        rusqlite::params![planted_ops.to_string(), commit_id],
8737                    )
8738                    .expect("plant legacy intent");
8739            }
8740            drop(connection);
8741
8742            // Startup recovery drops both, FIFO, and publishes one batch.
8743            let mut reopened = open(&path);
8744            assert_eq!(
8745                reopened.pending_commit_ids(),
8746                vec![successor.clone()],
8747                "{label}"
8748            );
8749            assert_eq!(
8750                reopened
8751                    .rejections()
8752                    .iter()
8753                    .map(|rejection| rejection.client_commit_id.clone())
8754                    .collect::<Vec<_>>(),
8755                vec![older.clone(), newer.clone()],
8756                "{label}"
8757            );
8758            assert!(
8759                reopened
8760                    .rejections()
8761                    .iter()
8762                    .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8763                        && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8764                            == Some(INVALID_STORED_VALUES_REASON)),
8765                "{label}"
8766            );
8767            for commit_id in [&older, &newer] {
8768                assert!(
8769                    matches!(
8770                        reopened
8771                            .commit_outcome(commit_id)
8772                            .expect("outcome")
8773                            .map(|o| o.status),
8774                        Some(CommitOutcomeStatus::Rejected)
8775                    ),
8776                    "{label}"
8777                );
8778            }
8779            assert_eq!(
8780                reopened
8781                    .query("SELECT id FROM tasks ORDER BY id", &[])
8782                    .unwrap(),
8783                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8784                "{label}"
8785            );
8786            let revision = reopened.local_revision();
8787            let batches = reopened.drain_change_batches();
8788            assert!(
8789                batches.iter().any(|batch| batch.rejections_changed
8790                    && batch.outcomes_changed
8791                    && batch.tables.iter().any(|change| change.table == "tasks")
8792                    && batch.revision == revision.to_string()),
8793                "{label}: {batches:?}"
8794            );
8795            drop(reopened);
8796
8797            // An ordinary reopen with nothing to recover publishes nothing and
8798            // leaves the revision alone.
8799            let mut stable = open(&path);
8800            assert_eq!(stable.local_revision(), revision, "{label}");
8801            assert!(stable.drain_change_batches().is_empty(), "{label}");
8802            assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8803            drop(stable);
8804            let _ = std::fs::remove_file(&path);
8805        }
8806    }
8807
8808    #[test]
8809    fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8810        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8811            {"name":"id","type":"string","nullable":false},
8812            {"name":"project_id","type":"string","nullable":false},
8813            {"name":"payload","type":"bytes","nullable":true}],
8814            "scopes":[{"pattern":"project:{project_id}"}]}]});
8815        for (label, second_op) in [
8816            (
8817                "delete carrying values",
8818                json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8819            ),
8820            (
8821                "baseVersion beyond the safe bound",
8822                json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8823                    "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8824                    "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8825            ),
8826        ] {
8827            let path = std::env::temp_dir().join(format!(
8828                "syncular-journal-shape-{}-{}.db",
8829                label.replace(' ', "-"),
8830                uuid::Uuid::new_v4()
8831            ));
8832            let open = |path: &std::path::Path| {
8833                SyncClient::open_path(
8834                    "journal-shape".into(),
8835                    &schema,
8836                    ClientLimits::default(),
8837                    path.to_str().unwrap(),
8838                )
8839                .expect("open client")
8840            };
8841            let values = |id: &str| {
8842                Map::from_iter([
8843                    ("id".to_owned(), json!(id)),
8844                    ("project_id".to_owned(), json!("p1")),
8845                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8846                ])
8847            };
8848            let upsert = |values| {
8849                vec![Mutation::Upsert {
8850                    table: "tasks".into(),
8851                    values,
8852                    base_version: None,
8853                }]
8854            };
8855            let mut client = open(&path);
8856            client.create_synced_tables().expect("create synced tables");
8857            let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
8858            drop(client);
8859
8860            let connection = Connection::open(&path).expect("raw connection");
8861            // The first operation triggers the drop with canonical cells (a
8862            // stored null for a non-nullable column); the second one is the
8863            // operation shape the journal reader refuses.
8864            let planted = json!([
8865                {
8866                    "op": "upsert",
8867                    "table": "tasks",
8868                    "rowId": "t1",
8869                    "baseVersion": null,
8870                    "values": {"id": "t1", "project_id": null,
8871                        "payload": {"$bytes": "0a"}}
8872                },
8873                second_op
8874            ]);
8875            connection
8876                .execute(
8877                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8878                    rusqlite::params![planted.to_string(), stale],
8879                )
8880                .expect("plant legacy intent");
8881            drop(connection);
8882
8883            let reopened = open(&path);
8884            let journal = reopened
8885                .commit_outcome(&stale)
8886                .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
8887                .unwrap_or_else(|| panic!("{label}: journal entry"));
8888            assert!(
8889                matches!(journal.status, CommitOutcomeStatus::Rejected),
8890                "{label}"
8891            );
8892            assert!(journal.operations.is_none(), "{label}");
8893            assert!(
8894                reopened
8895                    .commit_outcomes(CommitOutcomeQuery::default())
8896                    .is_ok(),
8897                "{label}: outcome list query"
8898            );
8899            assert!(reopened.pending_commit_ids().is_empty(), "{label}");
8900            drop(reopened);
8901            let _ = std::fs::remove_file(&path);
8902        }
8903    }
8904
8905    #[test]
8906    fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
8907        let schema = |version: i32, extra: bool| {
8908            let mut columns = vec![
8909                json!({"name":"id","type":"string","nullable":false}),
8910                json!({"name":"project_id","type":"string","nullable":false}),
8911                json!({"name":"payload","type":"bytes","nullable":true}),
8912            ];
8913            if extra {
8914                columns.push(json!({"name":"later","type":"string","nullable":true}));
8915            }
8916            json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
8917                "scopes":[{"pattern":"project:{project_id}"}]}]})
8918        };
8919        for (label, bump) in [
8920            ("same-version recreation", false),
8921            ("version-bump reset", true),
8922            ("version-only bump reset", true),
8923        ] {
8924            let path = std::env::temp_dir().join(format!(
8925                "syncular-legacy-reset-{}-{}.db",
8926                label.replace(' ', "-"),
8927                uuid::Uuid::new_v4()
8928            ));
8929            let v1 = schema(1, false);
8930            let open = |schema: &Value, path: &std::path::Path| {
8931                SyncClient::open_path(
8932                    "legacy-reset".into(),
8933                    schema,
8934                    ClientLimits::default(),
8935                    path.to_str().unwrap(),
8936                )
8937                .expect("open client")
8938            };
8939            let row = |id: &str| {
8940                Map::from_iter([
8941                    ("id".to_owned(), json!(id)),
8942                    ("project_id".to_owned(), json!("p1")),
8943                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8944                ])
8945            };
8946            let upsert = |values| {
8947                vec![Mutation::Upsert {
8948                    table: "tasks".into(),
8949                    values,
8950                    base_version: None,
8951                }]
8952            };
8953            let mut client = open(&v1, &path);
8954            client.create_synced_tables().expect("create synced tables");
8955            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8956            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8957            drop(client);
8958
8959            let connection = Connection::open(&path).expect("raw connection");
8960            let planted = json!([{
8961                "op": "upsert",
8962                "table": "tasks",
8963                "rowId": "t1",
8964                "baseVersion": null,
8965                "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
8966            }]);
8967            connection
8968                .execute(
8969                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8970                    rusqlite::params![planted.to_string(), stale],
8971                )
8972                .expect("plant legacy intent");
8973            drop(connection);
8974
8975            let target = if bump {
8976                schema(2, label == "version-bump reset")
8977            } else {
8978                v1.clone()
8979            };
8980            let reopened = open(&target, &path);
8981            let rejection = reopened
8982                .rejections()
8983                .iter()
8984                .find(|rejection| rejection.client_commit_id == stale)
8985                .unwrap_or_else(|| panic!("{label}: rejection"));
8986            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8987            assert_eq!(
8988                rejection
8989                    .details
8990                    .as_ref()
8991                    .and_then(|details| details.reason.as_deref()),
8992                Some(INVALID_STORED_VALUES_REASON),
8993                "{label}"
8994            );
8995            assert!(rejection.operation.is_some(), "{label}");
8996            assert_eq!(
8997                reopened.pending_commit_ids(),
8998                vec![successor.clone()],
8999                "{label}"
9000            );
9001            // The successor survives and still encodes. A bump that adds a
9002            // column leaves the older full-row upsert partial over the empty
9003            // base, so its visible row appears when the server answers it
9004            // (§7.1); the same-version recreation replays it locally.
9005            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
9006            if label == "version-bump reset" {
9007                assert_eq!(
9008                    reopened
9009                        .query("SELECT id FROM tasks ORDER BY id", &[])
9010                        .unwrap(),
9011                    Vec::<Map<String, Value>>::new(),
9012                    "{label}"
9013                );
9014                // The guarded bump reset owns the publication, so its change
9015                // batch carries the recovery's rejection, outcome, and table
9016                // domains at the revision it advanced to.
9017                let mut next = reopened;
9018                let revision = next.local_revision();
9019                let batches = next.drain_change_batches();
9020                assert!(
9021                    batches.iter().any(|batch| batch.rejections_changed
9022                        && batch.outcomes_changed
9023                        && batch.tables.iter().any(|change| change.table == "tasks")
9024                        && batch.revision == revision.to_string()),
9025                    "{label}: {batches:?}"
9026                );
9027                next.mutate(upsert(Map::from_iter([
9028                    ("id".to_owned(), json!("t3")),
9029                    ("project_id".to_owned(), json!("p1")),
9030                    ("payload".to_owned(), json!({"$bytes": "0b"})),
9031                    ("later".to_owned(), json!("v2")),
9032                ])))
9033                .expect("v2 authoring after the reset");
9034                assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
9035                assert_eq!(next.pending_payloads().len(), 2, "{label}");
9036                drop(next);
9037            } else {
9038                assert_eq!(
9039                    reopened
9040                        .query("SELECT id FROM tasks ORDER BY id", &[])
9041                        .unwrap(),
9042                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9043                    "{label}"
9044                );
9045                drop(reopened);
9046            }
9047
9048            // Queue and corrupt a new commit while the client stays open so only
9049            // recreation can recover it.
9050            if !bump {
9051                let mut recreated = open(&v1, &path);
9052                let fresh = recreated
9053                    .mutate(upsert(row("t3")))
9054                    .expect("fresh commit while open");
9055                recreated.drain_change_batches();
9056                let planted_fresh = json!([{
9057                    "op": "upsert",
9058                    "table": "tasks",
9059                    "rowId": "t3",
9060                    "baseVersion": null,
9061                    "values": {"id": "t3", "project_id": "p1",
9062                        "payload": {"$bytes": "zz"}}
9063                }]);
9064                let connection = Connection::open(&path).expect("raw connection");
9065                let affected = connection
9066                    .execute(
9067                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9068                        rusqlite::params![planted_fresh.to_string(), fresh],
9069                    )
9070                    .expect("plant fresh legacy intent");
9071                assert_eq!(
9072                    affected, 1,
9073                    "{label}: the in-place plant must hit the queued commit"
9074                );
9075                drop(connection);
9076                let revision_before = recreated.local_revision();
9077                recreated
9078                    .recreate_with_schema(&v1)
9079                    .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
9080                assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
9081                let fresh_rejection = recreated
9082                    .rejections()
9083                    .iter()
9084                    .find(|rejection| rejection.client_commit_id == fresh)
9085                    .expect("the in-place recovery rejection");
9086                assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9087                assert_eq!(
9088                    fresh_rejection
9089                        .details
9090                        .as_ref()
9091                        .and_then(|details| details.reason.as_deref()),
9092                    Some(INVALID_STORED_VALUES_REASON),
9093                    "{label}"
9094                );
9095                assert!(
9096                    matches!(
9097                        recreated
9098                            .commit_outcome(&fresh)
9099                            .expect("outcome read")
9100                            .map(|outcome| outcome.status),
9101                        Some(CommitOutcomeStatus::Rejected)
9102                    ),
9103                    "{label}"
9104                );
9105                assert!(recreated.local_revision() > revision_before, "{label}");
9106                let revision = recreated.local_revision();
9107                let batches = recreated.drain_change_batches();
9108                assert_eq!(batches.len(), 1, "{label}: one recovery batch");
9109                assert!(
9110                    batches.iter().any(|batch| batch.rejections_changed
9111                        && batch.outcomes_changed
9112                        && batch.tables.iter().any(|change| change.table == "tasks")
9113                        && batch.revision == revision.to_string()),
9114                    "{label}: {batches:?}"
9115                );
9116                assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
9117                assert_eq!(
9118                    recreated
9119                        .query("SELECT id FROM tasks ORDER BY id", &[])
9120                        .unwrap(),
9121                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9122                    "{label}: the recovered commit leaves no projection"
9123                );
9124                drop(recreated);
9125            }
9126            let _ = std::fs::remove_file(&path);
9127        }
9128    }
9129
9130    #[test]
9131    fn legacy_values_recover_over_a_synced_authoritative_row() {
9132        let path = std::env::temp_dir().join(format!(
9133            "syncular-authoritative-row-{}.db",
9134            uuid::Uuid::new_v4()
9135        ));
9136        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9137            {"name":"id","type":"string","nullable":false},
9138            {"name":"project_id","type":"string","nullable":false},
9139            {"name":"title","type":"string","nullable":false},
9140            {"name":"payload","type":"bytes","nullable":true}],
9141            "scopes":[{"pattern":"project:{project_id}"}]}]});
9142        let open = |path: &std::path::Path| {
9143            SyncClient::open_path(
9144                "authoritative-row".into(),
9145                &schema,
9146                ClientLimits::default(),
9147                path.to_str().unwrap(),
9148            )
9149            .expect("open client")
9150        };
9151        let row = |title: &str| {
9152            Map::from_iter([
9153                ("id".to_owned(), json!("t1")),
9154                ("project_id".to_owned(), json!("p1")),
9155                ("title".to_owned(), json!(title)),
9156                ("payload".to_owned(), json!({"$bytes": "0a"})),
9157            ])
9158        };
9159        let upsert = |values| {
9160            vec![Mutation::Upsert {
9161                table: "tasks".into(),
9162                values,
9163                base_version: None,
9164            }]
9165        };
9166        let mut client = open(&path);
9167        client.create_synced_tables().expect("create synced tables");
9168        // A server-confirmed row: base and visible carry the authoritative
9169        // values at version 1.
9170        client
9171            .conn
9172            .execute_batch(
9173                "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
9174                 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
9175            )
9176            .expect("seed authoritative row");
9177        let older = client.mutate(upsert(row("older"))).expect("older");
9178        let newer = client.mutate(upsert(row("newer"))).expect("newer");
9179        let successor = client.mutate(upsert(row("successor"))).expect("successor");
9180        drop(client);
9181
9182        let connection = Connection::open(&path).expect("raw connection");
9183        for commit_id in [&older, &newer] {
9184            let planted = json!([{
9185                "op": "upsert",
9186                "table": "tasks",
9187                "rowId": "t1",
9188                "baseVersion": null,
9189                "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9190                    "payload": {"$bytes": "zz"}}
9191            }]);
9192            connection
9193                .execute(
9194                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9195                    rusqlite::params![planted.to_string(), commit_id],
9196                )
9197                .expect("plant legacy intent");
9198        }
9199        drop(connection);
9200
9201        let reopened = open(&path);
9202        assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
9203        assert_eq!(
9204            reopened
9205                .rejections()
9206                .iter()
9207                .map(|rejection| rejection.client_commit_id.clone())
9208                .collect::<Vec<_>>(),
9209            vec![older.clone(), newer.clone()]
9210        );
9211        // The pending successor overlays the restored authoritative row, which
9212        // keeps its server version.
9213        assert_eq!(
9214            reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9215            Ok(vec![Map::from_iter([
9216                ("id".to_owned(), json!("t1")),
9217                ("title".to_owned(), json!("successor")),
9218                ("_syncular_version".to_owned(), json!(1)),
9219            ])])
9220        );
9221
9222        // A later rejection of the surviving successor restores the
9223        // authoritative values and version exactly.
9224        drop(reopened);
9225        let connection = Connection::open(&path).expect("raw connection");
9226        connection
9227            .execute(
9228                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9229                rusqlite::params![
9230                    json!([{
9231                        "op": "upsert",
9232                        "table": "tasks",
9233                        "rowId": "t1",
9234                        "baseVersion": null,
9235                        "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9236                            "payload": {"$bytes": "zz"}}
9237                    }])
9238                    .to_string(),
9239                    successor
9240                ],
9241            )
9242            .expect("plant successor intent");
9243        drop(connection);
9244        let late = open(&path);
9245        assert!(late.pending_commit_ids().is_empty());
9246        assert_eq!(
9247            late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9248            Ok(vec![Map::from_iter([
9249                ("id".to_owned(), json!("t1")),
9250                ("title".to_owned(), json!("authoritative")),
9251                ("_syncular_version".to_owned(), json!(1)),
9252            ])])
9253        );
9254        drop(late);
9255        let _ = std::fs::remove_file(&path);
9256    }
9257
9258    #[test]
9259    fn a_failed_value_recovery_commit_restores_every_side_effect() {
9260        let path = std::env::temp_dir().join(format!(
9261            "syncular-value-recovery-commit-{}.db",
9262            uuid::Uuid::new_v4()
9263        ));
9264        let path_str = path.to_str().expect("path");
9265        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9266            {"name":"id","type":"string","nullable":false},
9267            {"name":"project_id","type":"string","nullable":false},
9268            {"name":"payload","type":"bytes","nullable":true}],
9269            "scopes":[{"pattern":"project:{project_id}"}]}]});
9270        // A rollback-journal replica, so a second read connection can hold
9271        // SHARED across the recovery transaction's COMMIT.
9272        let conn = Connection::open(path_str).expect("replica connection");
9273        let mut client = SyncClient::with_connection(
9274            "value-recovery-commit".into(),
9275            &schema,
9276            ClientLimits::default(),
9277            conn,
9278        )
9279        .expect("replica");
9280        assert_eq!(
9281            client
9282                .conn
9283                .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
9284                .expect("journal mode"),
9285            "delete"
9286        );
9287        client.create_synced_tables().expect("create synced tables");
9288        let stale = client
9289            .mutate(vec![Mutation::Upsert {
9290                table: "tasks".into(),
9291                values: Map::from_iter([
9292                    ("id".to_owned(), json!("t1")),
9293                    ("project_id".to_owned(), json!("p1")),
9294                    ("payload".to_owned(), json!({"$bytes": "0a"})),
9295                ]),
9296                base_version: None,
9297            }])
9298            .expect("queued write");
9299        client.drain_change_batches();
9300        let affected = client
9301            .conn
9302            .execute(
9303                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9304                rusqlite::params![
9305                    json!([{
9306                        "op": "upsert",
9307                        "table": "tasks",
9308                        "rowId": "t1",
9309                        "baseVersion": null,
9310                        "values": {"id": "t1", "project_id": "p1",
9311                            "payload": {"$bytes": "zz"}}
9312                    }])
9313                    .to_string(),
9314                    stale
9315                ],
9316            )
9317            .expect("plant legacy intent");
9318        assert_eq!(affected, 1, "the planted row must exist");
9319        let revision = client.local_revision();
9320        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
9321        let prior_outbox = client.pending_commit_ids();
9322        let prior_rejections = client.rejections().len();
9323        let prior_changes = client.change_queue.len();
9324        let prior_intents = client.sync_intent_queue.len();
9325        let prior_failed = client.failed_commits.len();
9326        // The reader holds SHARED for the whole attempt: the recovery runs to
9327        // its publication and the COMMIT fails.
9328        let reader = Connection::open(path_str).expect("reader");
9329        reader
9330            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9331            .expect("reader snapshot");
9332        client
9333            .conn
9334            .busy_timeout(std::time::Duration::ZERO)
9335            .expect("busy timeout");
9336        let error = client
9337            .recreate_with_schema(&schema)
9338            .expect_err("a blocked COMMIT must refuse the recovery");
9339        assert!(error.starts_with("client.storage_busy:"), "{error}");
9340        assert!(client.conn.is_autocommit(), "the transaction is released");
9341        assert_eq!(client.local_revision(), revision);
9342        assert_eq!(client.pending_commit_ids(), prior_outbox);
9343        assert_eq!(client.rejections().len(), prior_rejections);
9344        assert_eq!(client.change_queue.len(), prior_changes);
9345        assert_eq!(client.sync_intent_queue.len(), prior_intents);
9346        assert_eq!(client.failed_commits.len(), prior_failed);
9347        assert_eq!(
9348            client.query("SELECT * FROM tasks", &[]).expect("rows"),
9349            rows
9350        );
9351        assert!(
9352            client
9353                .commit_outcome(&stale)
9354                .expect("outcome read")
9355                .is_none(),
9356            "the rolled-back recovery kept no outcome"
9357        );
9358        // The retry runs the same publication branch to completion.
9359        reader.execute_batch("ROLLBACK").expect("release reader");
9360        client
9361            .recreate_with_schema(&schema)
9362            .expect("retry after the blocked commit");
9363        assert!(client.pending_commit_ids().is_empty());
9364        assert_eq!(client.rejections().len(), prior_rejections + 1);
9365        assert!(client.local_revision() > revision);
9366        let batches = client.drain_change_batches();
9367        assert!(
9368            batches.iter().any(|batch| batch.rejections_changed
9369                && batch.outcomes_changed
9370                && batch.tables.iter().any(|change| change.table == "tasks")),
9371            "{batches:?}"
9372        );
9373        drop(client);
9374        let _ = std::fs::remove_file(&path);
9375    }
9376
9377    #[cfg(feature = "e2ee")]
9378    #[test]
9379    fn a_failed_recovery_restores_retained_failed_commits() {
9380        let path = std::env::temp_dir().join(format!(
9381            "syncular-value-recovery-retention-{}.db",
9382            uuid::Uuid::new_v4()
9383        ));
9384        let path_str = path.to_str().expect("path");
9385        let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
9386            {"name":"id","type":"string","nullable":false},
9387            {"name":"project_id","type":"string","nullable":false},
9388            {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
9389            "scopes":[{"pattern":"project:{project_id}"}]}]});
9390        let conn = Connection::open(path_str).expect("replica connection");
9391        let mut client = SyncClient::with_connection(
9392            "value-recovery-retention".into(),
9393            &schema,
9394            ClientLimits::default(),
9395            conn,
9396        )
9397        .expect("replica");
9398        client.create_synced_tables().expect("create synced tables");
9399        client.set_retain_failed_commits(true).expect("retention");
9400        // No keys configured: the §5.11 seal fails, so retention is exercised.
9401        client.set_encryption(crate::values::EncryptionConfig::default());
9402        client
9403            .mutate(vec![Mutation::Upsert {
9404                table: "secrets".into(),
9405                values: Map::from_iter([
9406                    ("id".to_owned(), json!("s1")),
9407                    ("project_id".to_owned(), json!("p1")),
9408                    ("note".to_owned(), json!("secret")),
9409                ]),
9410                base_version: None,
9411            }])
9412            .expect("queued write");
9413        client.drain_change_batches();
9414        let revision = client.local_revision();
9415        let prior_outbox = client.pending_commit_ids();
9416        let prior_rejections = client.rejections().len();
9417        let prior_failed = client.failed_commits.len();
9418        let reader = Connection::open(path_str).expect("reader");
9419        reader
9420            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9421            .expect("reader snapshot");
9422        client
9423            .conn
9424            .busy_timeout(std::time::Duration::ZERO)
9425            .expect("busy timeout");
9426        let error = client
9427            .drop_unencodable_outbox(true, None)
9428            .expect_err("a blocked COMMIT must refuse the recovery");
9429        assert!(error.starts_with("client.storage_busy:"), "{error}");
9430        assert_eq!(client.failed_commits.len(), prior_failed);
9431        assert_eq!(client.pending_commit_ids(), prior_outbox);
9432        assert_eq!(client.rejections().len(), prior_rejections);
9433        assert_eq!(client.local_revision(), revision);
9434        // The retry retains the failed commit and drops it from the outbox.
9435        reader.execute_batch("ROLLBACK").expect("release reader");
9436        client
9437            .drop_unencodable_outbox(true, None)
9438            .expect("retry after the blocked commit");
9439        assert_eq!(client.failed_commits.len(), prior_failed + 1);
9440        assert!(client.pending_commit_ids().is_empty());
9441        assert_eq!(client.rejections().len(), prior_rejections + 1);
9442        drop(client);
9443        let _ = std::fs::remove_file(&path);
9444    }
9445
9446    #[test]
9447    fn client_error_maps_legacy_codes_and_static_messages() {
9448        let code_only = ClientError::from("sync.unknown_table".to_owned());
9449        assert_eq!(code_only.code, "sync.unknown_table");
9450        assert_eq!(code_only.message, "the commit targets an unknown table");
9451        assert!(code_only.details.is_none());
9452
9453        let dynamic = ClientError::from(
9454            "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
9455        );
9456        assert_eq!(dynamic.code, "sync.invalid_request");
9457        assert_eq!(dynamic.message, "the authoring request is invalid");
9458        assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
9459            .as_str()
9460            .unwrap()
9461            .contains("patch cannot write scope column"));
9462
9463        let unclassified =
9464            ClientError::from("a commit must contain at least one operation".to_owned());
9465        assert_eq!(unclassified.code, "client.failed");
9466        assert_eq!(unclassified.message, "the local authoring operation failed");
9467        assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
9468            .as_str()
9469            .unwrap()
9470            .contains("at least one operation"));
9471
9472        // An unclassified QueryReadFailure keeps its numeric SQLite evidence.
9473        let metadata = ClientError::from(QueryReadFailure {
9474            code: None,
9475            sqlite_code: Some(1),
9476            sqlite_message: Some("generic SQLite prose".to_owned()),
9477            rollback_failure: Some(Box::new(QueryReadFailure::from(
9478                rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
9479            ))),
9480            message: "unclassified read".to_owned(),
9481        });
9482        assert_eq!(metadata.code, "client.failed");
9483        assert_eq!(metadata.message, "the local authoring operation failed");
9484        let details = metadata.details.as_ref().unwrap();
9485        assert_eq!(details["sqliteCode"], 1);
9486        assert_eq!(details["sqliteMessage"], "generic SQLite prose");
9487        assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
9488        assert_eq!(details["legacyCause"], "unclassified read");
9489    }
9490
9491    #[test]
9492    fn schema_marker_read_keeps_storage_classification() {
9493        let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9494            rusqlite::ffi::Error::new(5),
9495            Some("database is locked".to_owned()),
9496        ));
9497        assert!(busy.starts_with("client.storage_busy:"), "{busy}");
9498        let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9499            rusqlite::ffi::Error::new(1),
9500            Some("unreadable".to_owned()),
9501        ));
9502        assert_eq!(
9503            invalid,
9504            "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
9505        );
9506
9507        // A real exclusive lock on the marker read surfaces BUSY, not corrupt,
9508        // and the guard writes nothing.
9509        let path =
9510            std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
9511        let setup = Connection::open(&path).expect("setup connection");
9512        setup
9513            .execute_batch(
9514                "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n                 INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
9515            )
9516            .unwrap();
9517        let locker = Connection::open(&path).expect("locker connection");
9518        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9519        let reader = Connection::open(&path).expect("reader connection");
9520        reader.busy_timeout(std::time::Duration::ZERO).unwrap();
9521        let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
9522        assert!(locked.starts_with("client.storage_busy:"), "{locked}");
9523        locker.execute_batch("ROLLBACK").unwrap();
9524        assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
9525        let rows = setup
9526            .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
9527                row.get::<_, i64>(0)
9528            })
9529            .unwrap();
9530        assert_eq!(rows, 1, "the guard performed no writes");
9531        drop(reader);
9532        drop(locker);
9533        drop(setup);
9534        let _ = std::fs::remove_file(&path);
9535    }
9536
9537    #[cfg(feature = "crdt-yjs")]
9538    #[test]
9539    fn crdt_authoring_pre_read_preserves_storage_classification() {
9540        let path =
9541            std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
9542        let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
9543            {"name":"id","type":"string","nullable":false},
9544            {"name":"project_id","type":"string","nullable":false},
9545            {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
9546            "scopes":[{"pattern":"project:{project_id}"}]}]});
9547        let mut client = SyncClient::open_path(
9548            "crdt-busy".into(),
9549            &schema,
9550            ClientLimits::default(),
9551            path.to_str().unwrap(),
9552        )
9553        .expect("open client");
9554        client.create_synced_tables().expect("create synced tables");
9555        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
9556
9557        let locker = Connection::open(&path).expect("locker connection");
9558        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9559        let busy = client
9560            .crdt_apply_update("notes", "n1", "doc", &[])
9561            .expect_err("busy crdt pre-read");
9562        assert_eq!(busy.code, "client.storage_busy");
9563        assert!(busy.retryable);
9564        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
9565        assert!(client.pending_commit_ids().is_empty());
9566        assert_eq!(client.local_revision(), 0);
9567
9568        locker.execute_batch("ROLLBACK").unwrap();
9569        drop(locker);
9570        drop(client);
9571        let _ = std::fs::remove_file(&path);
9572    }
9573
9574    #[test]
9575    fn large_unique_import_copies_no_unrelated_tables() {
9576        let (mut client, image, table) = unique_import_fixture(10240);
9577        let pending = client.pending_commit_ids();
9578        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
9579            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
9580        assert!(matches!(
9581            client.apply_sqlite_image(
9582                &image,
9583                &table,
9584                true,
9585                &[("project_id".into(), vec!["p1".into()])],
9586                10240,
9587                7,
9588                "digest"
9589            ),
9590            Ok(10240)
9591        ));
9592        assert_eq!(client.overlay_rebuild_count.get(), 0);
9593        assert_eq!(client.pending_commit_ids(), pending);
9594        assert_eq!(
9595            client
9596                .query("SELECT count(*) AS n FROM tasks", &[])
9597                .unwrap()[0]["n"],
9598            10241
9599        );
9600        assert_eq!(
9601            client
9602                .query("SELECT count(*) AS n FROM tasks_fts", &[])
9603                .unwrap()[0]["n"],
9604            10241
9605        );
9606        assert_eq!(
9607            client
9608                .query("SELECT count(*) AS n FROM notes_fts", &[])
9609                .unwrap()[0]["n"],
9610            8192
9611        );
9612    }
9613
9614    #[test]
9615    #[ignore = "manual scaling benchmark; see bench/README.md"]
9616    fn benchmark_unique_image_import() {
9617        let mut samples = Vec::new();
9618        for rows in [10240, 51200, 102400] {
9619            for trial in 0..4 {
9620                let (mut client, image, table) = unique_import_fixture(rows);
9621                let start = std::time::Instant::now();
9622                assert!(
9623                    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)
9624                );
9625                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9626                assert_eq!(
9627                    client
9628                        .query("SELECT count(*) AS n FROM tasks", &[])
9629                        .unwrap()[0]["n"],
9630                    rows + 1
9631                );
9632                assert_eq!(
9633                    client
9634                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
9635                        .unwrap()[0]["n"],
9636                    rows + 1
9637                );
9638                assert_eq!(
9639                    client
9640                        .query("SELECT count(*) AS n FROM notes_fts", &[])
9641                        .unwrap()[0]["n"],
9642                    8192
9643                );
9644                assert_eq!(client.pending_commit_ids().len(), 1);
9645                if trial > 0 {
9646                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9647                }
9648            }
9649        }
9650        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});
9651        let output = serde_json::to_string_pretty(&artifact).unwrap();
9652        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9653            use std::io::Write;
9654            let mut file = std::fs::OpenOptions::new()
9655                .write(true)
9656                .create_new(true)
9657                .open(path)
9658                .unwrap();
9659            file.write_all(output.as_bytes()).unwrap();
9660        } else {
9661            println!("{output}");
9662        }
9663    }
9664
9665    #[test]
9666    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9667        let schema = json!({
9668            "version": 1,
9669            "tables": [{
9670                "name": "catalogue_codes",
9671                "primaryKey": "id",
9672                "columns": [
9673                    { "name": "id", "type": "string", "nullable": false },
9674                    { "name": "release_id", "type": "string", "nullable": false },
9675                    { "name": "code", "type": "string", "nullable": false },
9676                    { "name": "title", "type": "string", "nullable": false }
9677                ],
9678                "scopes": [{ "pattern": "release:{release_id}" }],
9679                "ftsIndexes": [{
9680                    "name": "catalogue_codes_fts",
9681                    "columns": ["code", "title"],
9682                    "tokenize": "unicode61 remove_diacritics 2"
9683                }]
9684            }]
9685        });
9686        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9687            .expect("FTS5 client");
9688        let insert_trigger: String = client
9689            .conn
9690            .query_row(
9691                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9692                [],
9693                |row| row.get(0),
9694            )
9695            .expect("insert trigger");
9696        let replace_guard: String = client
9697            .conn
9698            .query_row(
9699                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9700                [],
9701                |row| row.get(0),
9702            )
9703            .expect("replace guard");
9704        assert!(!insert_trigger.contains("DELETE FROM"));
9705        assert!(replace_guard.contains("BEFORE INSERT"));
9706        assert!(replace_guard.contains("WHEN EXISTS"));
9707        let search = |client: &SyncClient, query: &str| {
9708            client
9709                .query(
9710                    "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",
9711                    &[Value::from(query)],
9712                )
9713                .expect("FTS query")
9714        };
9715
9716        client
9717            .mutate(vec![Mutation::Upsert {
9718                table: "catalogue_codes".to_owned(),
9719                values: Map::from_iter([
9720                    ("id".to_owned(), Value::from("c1")),
9721                    ("release_id".to_owned(), Value::from("r1")),
9722                    ("code".to_owned(), Value::from("A01")),
9723                    ("title".to_owned(), Value::from("Cholera")),
9724                ]),
9725                base_version: None,
9726            }])
9727            .expect("insert code");
9728        assert_eq!(search(&client, "cholera").len(), 1);
9729        let prior = client
9730            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9731            .unwrap();
9732        client
9733            .conn
9734            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9735            .unwrap();
9736        client.create_synced_tables().unwrap();
9737        assert_eq!(
9738            client
9739                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9740                .unwrap(),
9741            prior
9742        );
9743        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();
9744        let rendered = serde_json::to_string(&plan).unwrap();
9745        assert!(rendered.contains("COVERING INDEX"));
9746        assert!(rendered.contains("INDEX 0:="));
9747        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9748        assert_eq!(search(&client, "cholera").len(), 1);
9749
9750        client
9751            .mutate(vec![Mutation::Upsert {
9752                table: "catalogue_codes".to_owned(),
9753                values: Map::from_iter([
9754                    ("id".to_owned(), Value::from("c1")),
9755                    ("release_id".to_owned(), Value::from("r1")),
9756                    ("code".to_owned(), Value::from("A01")),
9757                    ("title".to_owned(), Value::from("Enteric infection")),
9758                ]),
9759                base_version: None,
9760            }])
9761            .expect("update code");
9762        assert!(search(&client, "cholera").is_empty());
9763        assert_eq!(search(&client, "enteric").len(), 1);
9764
9765        client
9766            .mutate(vec![Mutation::Delete {
9767                table: "catalogue_codes".to_owned(),
9768                row_id: "c1".to_owned(),
9769                base_version: None,
9770            }])
9771            .expect("delete code");
9772        assert!(search(&client, "enteric").is_empty());
9773    }
9774
9775    #[test]
9776    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9777        let schema = json!({
9778            "version": 1,
9779            "tables": [{
9780                "name": "patient_notes",
9781                "primaryKey": "id",
9782                "columns": [
9783                    { "name": "id", "type": "string", "nullable": false },
9784                    { "name": "practice_id", "type": "string", "nullable": false },
9785                    { "name": "encryption_key_id", "type": "string", "nullable": false },
9786                    { "name": "title", "type": "string", "nullable": false }
9787                ],
9788                "scopes": [{ "pattern": "practice:{practice_id}" }],
9789                "ftsIndexes": [{
9790                    "name": "patient_notes_fts",
9791                    "columns": ["title"],
9792                    "tokenize": "unicode61 remove_diacritics 2"
9793                }]
9794            }]
9795        });
9796        let mut client = SyncClient::new(
9797            "local-purge-test".to_owned(),
9798            &schema,
9799            ClientLimits::default(),
9800        )
9801        .expect("local purge client");
9802        client
9803            .conn
9804            .execute(
9805                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9806                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9807                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9808                [],
9809            )
9810            .expect("seed base rows");
9811        client.overlay_dirty.set(true);
9812        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9813
9814        let note = |id: &str, key_id: &str, title: &str| {
9815            Map::from_iter([
9816                ("id".to_owned(), Value::from(id)),
9817                ("practice_id".to_owned(), Value::from("practice-1")),
9818                ("encryption_key_id".to_owned(), Value::from(key_id)),
9819                ("title".to_owned(), Value::from(title)),
9820            ])
9821        };
9822        let doomed = client
9823            .mutate(vec![
9824                Mutation::Upsert {
9825                    table: "patient_notes".to_owned(),
9826                    values: note("target", "key-revoked", "Target changed"),
9827                    base_version: None,
9828                },
9829                Mutation::Upsert {
9830                    table: "patient_notes".to_owned(),
9831                    values: note("unrelated", "key-held", "Unrelated changed"),
9832                    base_version: None,
9833                },
9834            ])
9835            .expect("doomed commit");
9836        let kept = client
9837            .mutate(vec![Mutation::Upsert {
9838                table: "patient_notes".to_owned(),
9839                values: note("kept", "key-held", "Kept optimistic"),
9840                base_version: None,
9841            }])
9842            .expect("kept commit");
9843        client.drain_change_batches();
9844
9845        let input = LocalDataPurgeInput {
9846            purge_id: "purge-001".to_owned(),
9847            targets: vec![LocalDataPurgeTarget {
9848                table: "patient_notes".to_owned(),
9849                selectors: BTreeMap::from([(
9850                    "encryption_key_id".to_owned(),
9851                    vec!["key-revoked".to_owned()],
9852                )]),
9853            }],
9854        };
9855        assert_eq!(
9856            client.purge_local_data(&input).expect("apply purge"),
9857            LocalDataPurgeResult {
9858                already_applied: false,
9859                purged_rows: 1,
9860                dropped_commits: 1,
9861            }
9862        );
9863        let rows = client
9864            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
9865            .expect("visible rows");
9866        assert_eq!(rows.len(), 2);
9867        assert_eq!(rows[0]["id"], "kept");
9868        assert_eq!(rows[1]["id"], "unrelated");
9869        assert_eq!(rows[1]["title"], "Unrelated original");
9870        let fts = client
9871            .query(
9872                "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'",
9873                &[],
9874            )
9875            .expect("fts query");
9876        assert!(fts.is_empty());
9877        assert_eq!(client.outbox.len(), 1);
9878        assert_eq!(client.outbox[0].client_commit_id, kept);
9879        let outcome = client
9880            .commit_outcome(&doomed)
9881            .expect("read doomed outcome")
9882            .expect("doomed outcome");
9883        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
9884        match &outcome.results[0] {
9885            CommitOperationOutcome::Error { rejection } => {
9886                assert_eq!(rejection.code, "client.local_data_purged");
9887                assert_eq!(rejection.client_commit_id, doomed);
9888            }
9889            other => panic!("expected local purge rejection, got {other:?}"),
9890        }
9891        assert_eq!(client.drain_change_batches().len(), 1);
9892        assert_eq!(
9893            client.purge_local_data(&input).expect("retry purge"),
9894            LocalDataPurgeResult {
9895                already_applied: true,
9896                purged_rows: 0,
9897                dropped_commits: 0,
9898            }
9899        );
9900        let conflicting = LocalDataPurgeInput {
9901            purge_id: input.purge_id.clone(),
9902            targets: vec![LocalDataPurgeTarget {
9903                table: "patient_notes".to_owned(),
9904                selectors: BTreeMap::from([(
9905                    "encryption_key_id".to_owned(),
9906                    vec!["key-held".to_owned()],
9907                )]),
9908            }],
9909        };
9910        assert!(client
9911            .purge_local_data(&conflicting)
9912            .expect_err("id collision must fail")
9913            .contains("already used with a different plan"));
9914    }
9915}
9916
9917impl SubState {
9918    fn name(self) -> &'static str {
9919        match self {
9920            SubState::Active => "active",
9921            SubState::Revoked => "revoked",
9922            SubState::Failed => "failed",
9923        }
9924    }
9925
9926    fn parse(value: &str) -> Self {
9927        match value {
9928            "revoked" => Self::Revoked,
9929            "failed" => Self::Failed,
9930            _ => Self::Active,
9931        }
9932    }
9933}
9934
9935#[derive(Debug, Clone)]
9936struct Subscription {
9937    id: String,
9938    table: String,
9939    requested: Vec<(String, Vec<String>)>,
9940    params: Option<String>,
9941    cursor: i64,
9942    /// §4.7 resume token, round-tripped opaquely.
9943    bootstrap_state: Option<String>,
9944    state: SubState,
9945    reason_code: Option<String>,
9946    /// Last effective scopes echoed while active (§3.3: persisted for the
9947    /// purge contract; each active echo replaces it).
9948    effective: Option<Vec<(String, Vec<String>)>>,
9949    synced_once: bool,
9950}
9951
9952#[cfg(test)]
9953mod subscription_rearm_tests {
9954    use super::*;
9955    use serde_json::json;
9956
9957    fn client() -> SyncClient {
9958        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9959            {"name":"id","type":"string","nullable":false},
9960            {"name":"project_id","type":"string","nullable":false}],
9961            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
9962        SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
9963    }
9964
9965    fn scopes() -> Vec<(String, Vec<String>)> {
9966        vec![("project_id".to_owned(), vec!["p1".to_owned()])]
9967    }
9968
9969    /// §3.3: the stored registration carries progress the re-arm must drop.
9970    fn store_revoked(client: &mut SyncClient) {
9971        client
9972            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9973            .expect("subscribe");
9974        let sub = &mut client.subs[0];
9975        sub.cursor = 41;
9976        sub.bootstrap_state = Some("resume".to_owned());
9977        sub.effective = Some(scopes());
9978        sub.state = SubState::Revoked;
9979        sub.reason_code = Some("sync.scope_revoked".to_owned());
9980        client.persist_sub(&client.subs[0]).expect("persist");
9981        client.sync_needed = false;
9982        client.sync_intent_queue.clear();
9983    }
9984
9985    #[test]
9986    fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
9987        let mut client = client();
9988        store_revoked(&mut client);
9989
9990        client
9991            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9992            .expect("re-arm");
9993
9994        let view = client.subscription_state("sub").expect("state");
9995        assert_eq!(view.status, "active");
9996        assert_eq!(view.cursor, -1);
9997        assert!(!view.has_resume_token);
9998        assert_eq!(view.effective_scopes, None);
9999        assert_eq!(view.reason_code, None);
10000        assert!(client.sync_needed, "a re-arm asks for a pull");
10001        assert!(matches!(
10002            client.drain_sync_intents().as_slice(),
10003            [SyncIntent::Interactive]
10004        ));
10005        let stored: String = client
10006            .conn
10007            .query_row(
10008                "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
10009                [],
10010                |row| row.get(0),
10011            )
10012            .expect("persisted row");
10013        let stored: Value = serde_json::from_str(&stored).expect("stored json");
10014        assert_eq!(stored["status"], json!("active"));
10015        assert_eq!(stored["cursor"], json!(-1));
10016        assert_eq!(stored["bootstrapState"], Value::Null);
10017        assert_eq!(stored["effectiveScopes"], Value::Null);
10018        assert_eq!(stored["reasonCode"], Value::Null);
10019    }
10020
10021    #[test]
10022    fn a_failed_registration_is_not_rearmed() {
10023        let mut client = client();
10024        client
10025            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10026            .expect("subscribe");
10027        client.subs[0].state = SubState::Failed;
10028        client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
10029        client.persist_sub(&client.subs[0]).expect("persist");
10030        client.sync_needed = false;
10031        client.sync_intent_queue.clear();
10032
10033        client
10034            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10035            .expect("subscribe");
10036
10037        let view = client.subscription_state("sub").expect("state");
10038        assert_eq!(view.status, "failed");
10039        assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
10040        assert!(!client.sync_needed);
10041        assert!(client.drain_sync_intents().is_empty());
10042    }
10043}
10044
10045#[derive(Debug, Clone)]
10046struct OutboxOp {
10047    upsert: bool,
10048    table: String,
10049    row_id: String,
10050    base_version: Option<i64>,
10051    /// Schema-agnostic local form (§0): driver JSON values, encoded with
10052    /// the current codec at send time. A key's presence IS the §6.1 sparse
10053    /// presence set — both the overlay and the wire payload derive from it.
10054    values: Option<Map<String, Value>>,
10055}
10056
10057impl From<&OutboxOp> for CommitOperation {
10058    fn from(operation: &OutboxOp) -> Self {
10059        Self {
10060            table: operation.table.clone(),
10061            row_id: operation.row_id.clone(),
10062            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
10063            base_version: operation.base_version,
10064            values: operation.values.clone(),
10065        }
10066    }
10067}
10068
10069#[derive(Debug, Clone)]
10070struct OutboxCommit {
10071    client_commit_id: String,
10072    ops: Vec<OutboxOp>,
10073}
10074
10075/// One commit's `(table, value keys)` per upsert operation — the classification
10076/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
10077/// can never disagree about which commits cannot re-encode.
10078fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
10079    commit
10080        .ops
10081        .iter()
10082        .filter(|operation| operation.upsert)
10083        .map(|operation| {
10084            (
10085                operation.table.as_str(),
10086                operation
10087                    .values
10088                    .as_ref()
10089                    .map(|values| values.keys().map(String::as_str).collect())
10090                    .unwrap_or_default(),
10091            )
10092        })
10093        .collect()
10094}
10095
10096type FailedCommit = (OutboxCommit, Map<String, Value>);
10097
10098#[derive(Debug, Clone)]
10099struct CompiledLocalDataPurgeTarget {
10100    table: String,
10101    selectors: Vec<(String, Vec<String>)>,
10102}
10103
10104struct StoredCommitOutcomeRow {
10105    sequence: i64,
10106    client_commit_id: String,
10107    status: String,
10108    recorded_at_ms: i64,
10109    results_json: String,
10110    operations_json: Option<String>,
10111    resolution: String,
10112    resolved_at_ms: Option<i64>,
10113    replacement_client_commit_id: Option<String>,
10114}
10115
10116impl StoredCommitOutcomeRow {
10117    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
10118        Ok(Self {
10119            sequence: row.get(0)?,
10120            client_commit_id: row.get(1)?,
10121            status: row.get(2)?,
10122            recorded_at_ms: row.get(3)?,
10123            results_json: row.get(4)?,
10124            operations_json: row.get(5)?,
10125            resolution: row.get(6)?,
10126            resolved_at_ms: row.get(7)?,
10127            replacement_client_commit_id: row.get(8)?,
10128        })
10129    }
10130}
10131
10132/// Section outcome distinguishing the §5.6 subscription-local fail-closed
10133/// path from a round-aborting failure (§1.4 rule 5).
10134enum SectionError {
10135    FailClosed,
10136    Abort(String, String),
10137}
10138
10139#[derive(Debug)]
10140pub(crate) struct RequestMeta {
10141    pushed_ids: Vec<String>,
10142    /// Subscription id → the request carried `cursor < 0` and no resume
10143    /// token (§5.6 first-page detection: a *fresh* bootstrap).
10144    fresh: Vec<(String, bool)>,
10145    pub(crate) accept: u8,
10146    /// §6.1 splitBatch: outbox commits held back from THIS request because
10147    /// the running operation count reached the push cap — the next round
10148    /// pushes them (`sync_needed` stays set while any remain).
10149    deferred_commits: usize,
10150}
10151
10152#[derive(Default)]
10153struct ChangeAccumulator {
10154    /// None means table-wide; Some is the exact scoped domain.
10155    tables: BTreeMap<String, Option<BTreeSet<String>>>,
10156    windows: BTreeMap<(String, String), BTreeSet<String>>,
10157    status: bool,
10158    conflicts: bool,
10159    rejections: bool,
10160    outcomes: bool,
10161}
10162
10163impl ChangeAccumulator {
10164    fn table(&mut self, table: &str) {
10165        self.tables.insert(table.to_owned(), None);
10166    }
10167
10168    fn scope(&mut self, table: &str, key: String) {
10169        match self.tables.get_mut(table) {
10170            Some(None) => {}
10171            Some(Some(keys)) => {
10172                keys.insert(key);
10173            }
10174            None => {
10175                self.tables
10176                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
10177            }
10178        }
10179    }
10180
10181    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
10182        self.windows
10183            .entry((base_key.to_owned(), table.to_owned()))
10184            .or_default()
10185            .insert(unit.to_owned());
10186    }
10187
10188    fn touched(&self) -> bool {
10189        !self.tables.is_empty()
10190            || !self.windows.is_empty()
10191            || self.status
10192            || self.conflicts
10193            || self.rejections
10194            || self.outcomes
10195    }
10196}
10197
10198/// §6.1: the server caps total operations per request (reference default
10199/// 500) and rejects the whole batch with `sync.too_many_operations`; the
10200/// client "splits and retries". Splitting happens at build time: commits are
10201/// included IN ORDER until the operation budget is spent, the rest wait for
10202/// the next round.
10203const PUSH_OPS_PER_REQUEST: usize = 500;
10204const MAX_LOCAL_PURGE_TARGETS: usize = 64;
10205const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
10206const MAX_LOCAL_PURGE_VALUES: usize = 128;
10207const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
10208pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
10209
10210/// Dirty visible tables; `None` means a whole-replica recovery/reset.
10211struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
10212
10213impl OverlayDirty {
10214    fn clean() -> Self {
10215        Self(RefCell::new(Some(BTreeSet::new())))
10216    }
10217    fn get(&self) -> bool {
10218        self.0
10219            .borrow()
10220            .as_ref()
10221            .is_none_or(|tables| !tables.is_empty())
10222    }
10223    fn set(&self, dirty: bool) {
10224        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
10225    }
10226    fn table(&self, table: &str) {
10227        if let Some(tables) = self.0.borrow_mut().as_mut() {
10228            tables.insert(table.to_owned());
10229        }
10230    }
10231    fn snapshot(&self) -> Option<BTreeSet<String>> {
10232        self.0.borrow().clone()
10233    }
10234    fn restore(&self, tables: Option<BTreeSet<String>>) {
10235        *self.0.borrow_mut() = tables;
10236    }
10237}
10238
10239pub struct SyncClient {
10240    #[cfg(feature = "bench-internals")]
10241    benchmark_phases: Recorder,
10242    conn: Connection,
10243    schema: ClientSchema,
10244    client_id: String,
10245    limits: ClientLimits,
10246    subs: Vec<Subscription>,
10247    outbox: Vec<OutboxCommit>,
10248    failed_commits: Vec<FailedCommit>,
10249    retain_failed_commits: bool,
10250    conflicts: Vec<ConflictRecord>,
10251    rejections: Vec<RejectionRecord>,
10252    schema_floor: Option<SchemaFloor>,
10253    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
10254    lease_state: Option<LeaseState>,
10255    /// §1.6: the schema-floor response stops syncing until an upgrade.
10256    stopped: bool,
10257    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
10258    upgrading: bool,
10259    /// §8.4 coalesced sync-needed signal.
10260    sync_needed: bool,
10261    /// §8.8: how sync rounds treat the realtime binding, and its exact
10262    /// availability state with the evidence behind a lost or refused one.
10263    realtime_policy: RealtimePolicy,
10264    realtime_state: RealtimeState,
10265    realtime_reason_code: Option<String>,
10266    realtime_retry_delay_ms: Option<u64>,
10267    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
10268    presence: HashMap<String, HashMap<String, PresencePeer>>,
10269    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
10270    /// host may pin it (conformance runs on a virtual clock).
10271    now_ms: Option<i64>,
10272    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
10273    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
10274    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
10275    encryption: crate::values::EncryptionConfig,
10276    /// Fail-closed host bootstrap gate. While set, command hosts permit only
10277    /// status/lifecycle inspection and an exact authorized local purge.
10278    security_preflight: bool,
10279    transport_enabled: bool,
10280    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
10281    /// means the feature is off; the descriptor write and the container orphan
10282    /// sweep still happen.
10283    previous_version: Option<PreviousVersionContextConfig>,
10284    /// Per-table primary-key upsert SQL, built once per (full table name) —
10285    /// the row write path runs per row during bootstrap (§5.6), so the SQL
10286    /// string (and, via `prepare_cached`, its compiled statement) is reused
10287    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
10288    /// schema reset (the column lists may have changed).
10289    insert_sql: RefCell<HashMap<String, String>>,
10290    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
10291    /// reset and restart explicitly invalidate every table.
10292    overlay_dirty: OverlayDirty,
10293    // First SQLite failure of the current operation, before savepoint cleanup.
10294    storage_failure: RefCell<Option<QueryReadFailure>>,
10295    /// Test-only structural performance signal: response processing must not
10296    /// turn a batch of acknowledgements into one full overlay rebuild each.
10297    #[cfg(test)]
10298    overlay_rebuild_count: Cell<usize>,
10299    #[cfg(test)]
10300    acknowledged_replay_count: Cell<usize>,
10301    /// Test-only structural performance signal: outcome retention is enforced
10302    /// once per response rather than once per acknowledgement.
10303    #[cfg(test)]
10304    outcome_prune_count: Cell<usize>,
10305    /// Exact observer-transaction output drained by command/FFI hosts.
10306    progress: ProgressObserver,
10307    change_queue: VecDeque<ClientChangeBatch>,
10308    sync_intent_queue: VecDeque<SyncIntent>,
10309    /// Explicit exponential retry policy for transient transport failures.
10310    retry_delay_ms: u64,
10311    /// §7.6: the background retry the current round scheduled, if any.
10312    round_retry_delay_ms: Option<u64>,
10313    /// A round whose response never completed (§1.5): the client keeps its
10314    /// code and reports it on every following request until a round receives a
10315    /// complete, decodable response. The server already recorded the round, so
10316    /// nothing else can tell it the round failed on this side.
10317    round_failure: Option<String>,
10318    active_round: Option<uuid::Uuid>,
10319    last_round: Option<DiagnosticLastRound>,
10320    last_change: Option<DiagnosticLastChange>,
10321    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
10322    query_failures: Vec<DiagnosticQueryFailure>,
10323    /// SPEC §1.4 rule 4: the open coalesced apply transaction of a response.
10324    coalesced: Option<CoalescedApply>,
10325    /// A coalesced apply transaction of the current response failed; every
10326    /// later observation of that response fails until it ends.
10327    coalesced_failure: Option<String>,
10328}
10329
10330/// SPEC §1.4 rule 4: the frames and blocks of one response share one local
10331/// write transaction of at most `COALESCED_APPLY_ROWS` rows. The in-memory
10332/// state captured at its `BEGIN` is restored when its `COMMIT` fails.
10333struct CoalescedApply {
10334    rows: usize,
10335    subs: Vec<Subscription>,
10336    outbox: Vec<OutboxCommit>,
10337    failed_commits: Vec<FailedCommit>,
10338    conflicts: Vec<ConflictRecord>,
10339    rejections: Vec<RejectionRecord>,
10340    schema_floor: Option<SchemaFloor>,
10341    lease_state: Option<LeaseState>,
10342    stopped: bool,
10343    upgrading: bool,
10344    sync_needed: bool,
10345    overlay_dirty: Option<BTreeSet<String>>,
10346    change_queue: usize,
10347    sync_intent_queue: usize,
10348    last_change: Option<DiagnosticLastChange>,
10349}
10350
10351const COALESCED_APPLY_ROWS: usize = 1024;
10352
10353pub(crate) fn quote_ident(name: &str) -> String {
10354    format!("\"{}\"", name.replace('"', "\"\""))
10355}
10356/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
10357/// previous-version module reads and writes the SAME records as the client
10358/// instead of growing a parallel meta layer.
10359pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
10360    conn.query_row(
10361        "SELECT value FROM _syncular_meta WHERE key = ?1",
10362        rusqlite::params![key],
10363        |row| row.get::<_, String>(0),
10364    )
10365    .ok()
10366}
10367
10368pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
10369    let _ = conn.execute(
10370        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
10371        rusqlite::params![key, value],
10372    );
10373}
10374
10375pub(crate) fn meta_delete(conn: &Connection, key: &str) {
10376    let _ = conn.execute(
10377        "DELETE FROM _syncular_meta WHERE key = ?1",
10378        rusqlite::params![key],
10379    );
10380}
10381
10382/// Seek by the existing key while retaining the exact legacy text match.
10383fn row_id_predicate(table: &TableSchema) -> String {
10384    let key = quote_ident(&table.primary_key);
10385    let text_match = format!("CAST({key} AS TEXT) = ?1");
10386    match table.columns[table.pk_index].ty {
10387        ColumnType::String | ColumnType::Json => {
10388            format!("{key} = ?1 AND {text_match}")
10389        }
10390        // Native columns have no declared SQLite affinity. Match the stored
10391        // integer bind type explicitly. Unary + removes expression affinity so
10392        // SQLite can seek the typeless key; the text check preserves exact IDs.
10393        ColumnType::Integer | ColumnType::Boolean => {
10394            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
10395        }
10396        // REAL-to-text conversion can round distinct values to the same text.
10397        // Narrowing those matches by numeric equality would change behavior.
10398        _ => text_match,
10399    }
10400}
10401
10402fn is_local_operation_code_like(value: &str) -> bool {
10403    let bytes = value.as_bytes();
10404    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
10405        && bytes
10406            .iter()
10407            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
10408}
10409
10410fn base_table(name: &str) -> String {
10411    quote_ident(&format!("_syncular_base_{name}"))
10412}
10413
10414/// §4.8: a stable, server-opaque key for a window base — table + variable +
10415/// canonical fixed scopes. Two `set_window` calls with the same base
10416/// address the same registry rows.
10417/// §4.8 deferred eviction record: (sub id, table, effective scope map).
10418type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
10419
10420fn window_base_key(base: &WindowBase) -> String {
10421    format!(
10422        "{}\0{}\0{}",
10423        base.table,
10424        base.variable,
10425        canonical_scope_json(&base.fixed_scopes)
10426    )
10427}
10428
10429/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
10430fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
10431    let mut scopes = base.fixed_scopes.clone();
10432    scopes.retain(|(k, _)| k != &base.variable);
10433    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
10434    scopes
10435}
10436
10437/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
10438/// echoed not interpreted by the server, so the exact hash is client
10439/// convention; SHA-256 matches the SPEC's worked example.
10440fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
10441    let canonical = canonical_scope_json(&unit_scopes(base, unit));
10442    let digest = Sha256::digest(canonical.as_bytes());
10443    let hex = bytes_to_hex(&digest);
10444    format!("w:{}:{}", base.table, &hex[..16])
10445}
10446
10447fn visible_table(name: &str) -> String {
10448    quote_ident(name)
10449}
10450
10451const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
10452
10453/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
10454/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
10455fn is_synced_table_name(name: &str) -> bool {
10456    if name.starts_with("sqlite_") {
10457        return false;
10458    }
10459    if name.starts_with("_syncular_base_") {
10460        return true; // the base half of a synced table pair
10461    }
10462    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
10463    !name.starts_with("_syncular_")
10464}
10465
10466/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
10467fn blob_id_for(bytes: &[u8]) -> String {
10468    let digest = Sha256::digest(bytes);
10469    format!("sha256:{}", bytes_to_hex(&digest))
10470}
10471
10472/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
10473/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
10474/// per row on the §5.6 bootstrap path), scalars bind owned.
10475enum RowParam<'a> {
10476    Cell(&'a Option<ColumnValue>),
10477    Version(i64),
10478}
10479
10480impl rusqlite::ToSql for RowParam<'_> {
10481    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
10482        Ok(match self {
10483            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
10484            RowParam::Cell(cell) => match cell {
10485                None => ToSqlOutput::Owned(SqlValue::Null),
10486                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
10487                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
10488                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
10489                Some(ColumnValue::Boolean(b)) => {
10490                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
10491                }
10492                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
10493                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
10494                }
10495                // §5.10: crdt bytes store as BLOB, like bytes.
10496                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
10497                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
10498                }
10499            },
10500        })
10501    }
10502}
10503
10504// Retain a typed primary key across the current import chunk without copying row payloads.
10505fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
10506    match param.to_sql().map_err(|error| error.to_string())? {
10507        ToSqlOutput::Owned(value) => Ok(value),
10508        ToSqlOutput::Borrowed(value) => {
10509            SqlValue::try_from(value).map_err(|error| error.to_string())
10510        }
10511        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
10512    }
10513}
10514
10515/// §5.3 image cell → bind parameter, strict per the declared column type
10516/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
10517/// nullable). Mismatches are image-producer violations. Returns the cell
10518/// borrowed when the stored representation already matches what the row
10519/// codec would write, or the normalized scalar (boolean → 0/1, float from
10520/// INTEGER → REAL) otherwise — the local write is byte-identical to the
10521/// old convert-then-insert path without allocating per cell.
10522fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
10523    use ssp2::segment::ColumnType;
10524    let mismatch = || {
10525        Err(format!(
10526            "image column {:?} holds a value of the wrong type",
10527            column.name
10528        ))
10529    };
10530    match value {
10531        ValueRef::Null => {
10532            if !column.nullable {
10533                return Err(format!(
10534                    "image column {:?} is NULL but not nullable",
10535                    column.name
10536                ));
10537            }
10538            Ok(ToSqlOutput::Owned(SqlValue::Null))
10539        }
10540        ValueRef::Integer(i) => match column.ty {
10541            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
10542            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
10543            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
10544            _ => mismatch(),
10545        },
10546        ValueRef::Real(_) => match column.ty {
10547            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
10548            _ => mismatch(),
10549        },
10550        ValueRef::Text(t) => {
10551            std::str::from_utf8(t)
10552                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
10553            match column.ty {
10554                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
10555                    Ok(ToSqlOutput::Borrowed(value))
10556                }
10557                _ => mismatch(),
10558            }
10559        }
10560        ValueRef::Blob(_) => match column.ty {
10561            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
10562            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
10563            _ => mismatch(),
10564        },
10565    }
10566}
10567
10568pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
10569    use rusqlite::types::ValueRef;
10570    match value {
10571        ValueRef::Null => Value::Null,
10572        ValueRef::Integer(i) => match column.ty {
10573            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
10574            ssp2::segment::ColumnType::Float => {
10575                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
10576            }
10577            _ => Value::from(i),
10578        },
10579        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
10580        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
10581        ValueRef::Blob(b) => {
10582            let mut map = Map::new();
10583            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
10584            Value::Object(map)
10585        }
10586    }
10587}
10588
10589/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
10590/// Objects are accepted in lossless `{"$bytes": hex}` and
10591/// `{"$bigint": decimal}` envelope forms.
10592fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
10593    Ok(match value {
10594        Value::Null => SqlValue::Null,
10595        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
10596        Value::Number(n) => {
10597            if let Some(i) = n.as_i64() {
10598                SqlValue::Integer(i)
10599            } else if let Some(f) = n.as_f64() {
10600                SqlValue::Real(f)
10601            } else {
10602                return Err(format!("query param number {n} is out of range"));
10603            }
10604        }
10605        Value::String(s) => SqlValue::Text(s.clone()),
10606        Value::Object(_) => {
10607            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
10608                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
10609            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
10610                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
10611                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
10612                })?)
10613            } else {
10614                return Err(
10615                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
10616                        .to_owned(),
10617                );
10618            }
10619        }
10620        Value::Array(_) => return Err("query array params are not supported".to_owned()),
10621    })
10622}
10623
10624/// Map a rusqlite value with no schema column to consult (arbitrary query
10625/// output): integers/reals/text pass through by stored affinity, blobs ride
10626/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
10627/// schema column type to recover booleans/floats/json.
10628fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
10629    use rusqlite::types::ValueRef;
10630    match value {
10631        ValueRef::Null => Value::Null,
10632        ValueRef::Integer(i) => {
10633            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
10634            // ordinary UI-sized integers ergonomic and envelope only values
10635            // beyond JavaScript's safe range.
10636            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
10637            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
10638                Value::from(i)
10639            } else {
10640                let mut map = Map::new();
10641                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
10642                Value::Object(map)
10643            }
10644        }
10645        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
10646        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
10647        ValueRef::Blob(b) => {
10648            let mut map = Map::new();
10649            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
10650            Value::Object(map)
10651        }
10652    }
10653}
10654
10655/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
10656fn query_connection(
10657    conn: &Connection,
10658    sql: &str,
10659    params: &[QueryValue],
10660) -> Result<Vec<QueryRow>, QueryReadFailure> {
10661    crate::query_guard::assert_read_only_query(sql)?;
10662    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
10663    let bound: Vec<SqlValue> = params
10664        .iter()
10665        .map(json_param_to_sql)
10666        .collect::<Result<_, _>>()?;
10667    let mut stmt = conn.prepare(&lowered_sql)?;
10668    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
10669    let bound_refs: Vec<&dyn rusqlite::ToSql> =
10670        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
10671    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
10672    let mut out = Vec::new();
10673    while let Some(row) = sql_rows.next()? {
10674        let mut record = Map::new();
10675        for (i, name) in column_names.iter().enumerate() {
10676            let value = row.get_ref(i)?;
10677            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
10678        }
10679        out.push(record);
10680    }
10681    Ok(out)
10682}
10683
10684fn persisted_window_state(
10685    conn: &Connection,
10686    base: &WindowBase,
10687) -> Result<WindowState, QueryReadFailure> {
10688    let mut stmt = conn.prepare(
10689        "SELECT windows.unit, subscriptions.state_json
10690           FROM _syncular_windows AS windows
10691           JOIN _syncular_subscriptions AS subscriptions
10692             ON subscriptions.id = windows.sub_id
10693          WHERE windows.base = ?1
10694          ORDER BY windows.unit ASC",
10695    )?;
10696    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
10697        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10698    })?;
10699    let mut units = Vec::new();
10700    let mut pending = Vec::new();
10701    for row in rows {
10702        let (unit, raw) = row?;
10703        let state: Value = serde_json::from_str(&raw)
10704            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
10705        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
10706            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
10707            || state
10708                .get("bootstrapState")
10709                .is_some_and(|value| !value.is_null());
10710        if is_pending {
10711            pending.push(unit.clone());
10712        }
10713        units.push(unit);
10714    }
10715    Ok(WindowState { units, pending })
10716}
10717
10718/// One read-only statement in a §7.5 snapshot read.
10719#[derive(Debug, Clone, Copy)]
10720pub struct SnapshotStatement<'a> {
10721    pub sql: &'a str,
10722    pub params: &'a [Value],
10723}
10724
10725/// A §7.5 atomic read request: read-only statements, window coverage,
10726/// subscription catch-up states, and commit deliveries, all resolved from one
10727/// SQLite read transaction and one revision.
10728#[derive(Debug, Clone, Default)]
10729pub struct SnapshotReadRequest<'a> {
10730    pub statements: Vec<SnapshotStatement<'a>>,
10731    pub coverage: &'a [WindowCoverage],
10732    pub subscriptions: Vec<String>,
10733    pub commit_ids: Vec<String>,
10734}
10735
10736#[cfg(test)]
10737thread_local! {
10738    /// Test-only rendezvous that runs between the statements of one snapshot
10739    /// read. A test uses it to commit on another connection mid-read and prove
10740    /// the read transaction's snapshot and revision stay stable.
10741    static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
10742        std::cell::RefCell::new(None);
10743}
10744
10745#[cfg(test)]
10746fn run_snapshot_read_hook() {
10747    SNAPSHOT_READ_HOOK.with(|slot| {
10748        if let Some(hook) = slot.borrow_mut().take() {
10749            hook();
10750        }
10751    });
10752}
10753
10754/// §7.5: resolve a request over one SQLite read transaction. The revision,
10755/// every statement's rows, coverage, catch-up states, and deliveries are
10756/// captured under one `SAVEPOINT`; any statement's failure rolls the whole
10757/// read back and releases the connection.
10758fn snapshot_read_connection(
10759    conn: &Connection,
10760    request: &SnapshotReadRequest<'_>,
10761) -> Result<SnapshotRead, QueryReadFailure> {
10762    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
10763    let corrupt = |message: &str| QueryReadFailure {
10764        code: Some("sync.local_corrupt"),
10765        sqlite_code: None,
10766        sqlite_message: None,
10767        rollback_failure: None,
10768        message: message.to_owned(),
10769    };
10770    let stored_failure = |error: rusqlite::Error, message: &str| match error {
10771        rusqlite::Error::Utf8Error(..)
10772        | rusqlite::Error::InvalidColumnType(..)
10773        | rusqlite::Error::FromSqlConversionFailure(..)
10774        | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
10775        _ => QueryReadFailure::from(error),
10776    };
10777    let result: Result<SnapshotRead, QueryReadFailure> = (|| {
10778        // The durable revision as a canonical decimal `u64`; a missing marker
10779        // is the legacy zero, and any other non-canonical value is corrupt.
10780        let revision: Option<String> = conn
10781            .query_row(
10782                "SELECT value FROM _syncular_meta WHERE key = ?1",
10783                rusqlite::params![LOCAL_REVISION_KEY],
10784                |row| row.get(0),
10785            )
10786            .optional()
10787            .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
10788        let revision = match revision {
10789            None => "0".to_owned(),
10790            Some(value)
10791                if value == "0"
10792                    || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10793                        && value.bytes().all(|b| b.is_ascii_digit())
10794                        && value.parse::<u64>().is_ok()) =>
10795            {
10796                value
10797            }
10798            Some(_) => return Err(corrupt("persisted local revision is invalid")),
10799        };
10800        let mut queries = Vec::with_capacity(request.statements.len());
10801        for statement in &request.statements {
10802            queries.push(query_connection(conn, statement.sql, statement.params)?);
10803            #[cfg(test)]
10804            run_snapshot_read_hook();
10805        }
10806        let mut pending = Vec::new();
10807        let mut missing = Vec::new();
10808        for requested in request.coverage {
10809            let base_key = window_base_key(&requested.base);
10810            let state = persisted_window_state(conn, &requested.base)?;
10811            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
10812                let reference = WindowUnitRef {
10813                    base_key: base_key.clone(),
10814                    unit: unit.clone(),
10815                };
10816                if !state.units.iter().any(|held| held == &unit) {
10817                    missing.push(reference);
10818                } else if state.pending.iter().any(|held| held == &unit) {
10819                    pending.push(reference);
10820                }
10821            }
10822        }
10823        let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
10824        for id in &request.subscriptions {
10825            let row: Option<(String, String)> = conn
10826                .query_row(
10827                    "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
10828                    rusqlite::params![id],
10829                    |row| Ok((row.get(0)?, row.get(1)?)),
10830                )
10831                .optional()
10832                .map_err(|error| {
10833                    stored_failure(error, "persisted subscription state is invalid")
10834                })?;
10835            let Some((table, raw)) = row else {
10836                subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
10837                continue;
10838            };
10839            // Every persisted field is required to be well typed; a corrupt
10840            // record fails typed instead of inventing a state.
10841            let state: Value = serde_json::from_str(&raw)
10842                .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10843            let status = match state.get("status").and_then(Value::as_str) {
10844                Some(value @ ("active" | "revoked" | "failed")) => value,
10845                _ => return Err(corrupt("persisted subscription state is invalid")),
10846            };
10847            let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
10848                return Err(corrupt("persisted subscription state is invalid"));
10849            };
10850            if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
10851                return Err(corrupt("persisted subscription state is invalid"));
10852            }
10853            let effective_scopes = state
10854                .get("effectiveScopes")
10855                .filter(|value| !value.is_null());
10856            if let Some(scopes) = effective_scopes {
10857                json_to_scope_map(scopes)
10858                    .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10859            }
10860            let has_resume_token = match state.get("bootstrapState") {
10861                None | Some(Value::Null) => false,
10862                Some(Value::String(_)) => true,
10863                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10864            };
10865            // A present reasonCode must be a string; a non-string value is a
10866            // corrupt record, never silently dropped.
10867            let reason_code = match state.get("reasonCode") {
10868                None | Some(Value::Null) => None,
10869                Some(Value::String(value)) => Some(value.clone()),
10870                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10871            };
10872            let active = status == "active";
10873            subscriptions.push(SubscriptionCatchup::Known {
10874                id: id.clone(),
10875                table,
10876                status: status.to_owned(),
10877                cursor,
10878                has_resume_token,
10879                bootstrap_complete: active && cursor >= 0 && !has_resume_token,
10880                known_pending_pages: active && (has_resume_token || cursor < 0),
10881                effective_scopes: effective_scopes.cloned(),
10882                reason_code,
10883            });
10884        }
10885        let mut deliveries = Vec::with_capacity(request.commit_ids.len());
10886        for id in &request.commit_ids {
10887            let pending: bool = conn.query_row(
10888                "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
10889                rusqlite::params![id],
10890                |row| row.get(0),
10891            )?;
10892            if pending {
10893                deliveries.push(CommitDelivery::Pending {
10894                    client_commit_id: id.clone(),
10895                });
10896                continue;
10897            }
10898            let row = conn
10899                .query_row(
10900                    "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
10901                            resolution, resolved_at_ms, replacement_client_commit_id
10902                       FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
10903                    rusqlite::params![id],
10904                    StoredCommitOutcomeRow::from_row,
10905                )
10906                .optional()
10907                .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
10908            deliveries.push(match row {
10909                Some(row) => CommitDelivery::Known {
10910                    client_commit_id: id.clone(),
10911                    outcome: SyncClient::outcome_from_row(row)
10912                        .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
10913                },
10914                None => CommitDelivery::Unknown {
10915                    client_commit_id: id.clone(),
10916                },
10917            });
10918        }
10919        Ok(SnapshotRead {
10920            revision,
10921            queries,
10922            coverage: CoverageSnapshot {
10923                complete: pending.is_empty() && missing.is_empty(),
10924                pending,
10925                missing,
10926            },
10927            subscriptions,
10928            deliveries,
10929        })
10930    })();
10931    match result {
10932        Ok(read) => {
10933            conn.execute_batch("RELEASE syncular_snapshot_read")?;
10934            Ok(read)
10935        }
10936        Err(mut error) => {
10937            if let Err(rollback) = conn
10938                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
10939            {
10940                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
10941            }
10942            Err(error)
10943        }
10944    }
10945}
10946
10947fn snapshot_connection(
10948    conn: &Connection,
10949    sql: &str,
10950    params: &[Value],
10951    coverage: &[WindowCoverage],
10952) -> Result<QuerySnapshot, QueryReadFailure> {
10953    let read = snapshot_read_connection(
10954        conn,
10955        &SnapshotReadRequest {
10956            statements: vec![SnapshotStatement { sql, params }],
10957            coverage,
10958            subscriptions: Vec::new(),
10959            commit_ids: Vec::new(),
10960        },
10961    )?;
10962    Ok(QuerySnapshot {
10963        revision: read.revision,
10964        rows: read
10965            .queries
10966            .into_iter()
10967            .next()
10968            .expect("single-query snapshot returns one result"),
10969        coverage: read.coverage,
10970    })
10971}
10972
10973/// A long-lived read-only SQLite sidecar for latency-critical native views.
10974/// Network rounds stay serialized on the mutable core owner, while atomic
10975/// query snapshots use this independent connection and therefore never queue
10976/// behind HTTP/WebSocket latency.
10977pub struct FileQuerySnapshotReader {
10978    path: String,
10979    conn: Option<Connection>,
10980}
10981
10982impl FileQuerySnapshotReader {
10983    #[must_use]
10984    pub fn new(path: impl Into<String>) -> Self {
10985        Self {
10986            path: path.into(),
10987            conn: None,
10988        }
10989    }
10990
10991    fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
10992        if self.conn.is_none() {
10993            let conn = Connection::open_with_flags(
10994                &self.path,
10995                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
10996            )
10997            .map_err(QueryReadFailure::from)?;
10998            conn.busy_timeout(std::time::Duration::from_millis(250))
10999                .map_err(QueryReadFailure::from)?;
11000            self.conn = Some(conn);
11001        }
11002        self.conn
11003            .as_ref()
11004            .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
11005    }
11006
11007    /// One atomic read over the shared primitive: every statement, coverage,
11008    /// catch-up, and delivery under one `SAVEPOINT` and one revision.
11009    pub fn snapshot_read(
11010        &mut self,
11011        request: &SnapshotReadRequest<'_>,
11012    ) -> Result<SnapshotRead, QueryReadFailure> {
11013        snapshot_read_connection(self.connection()?, request)
11014    }
11015
11016    /// The current durable local revision. A host can skip a read when it is
11017    /// unchanged; a corrupt or unreadable marker fails typed.
11018    pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
11019        Ok(self
11020            .snapshot_read(&SnapshotReadRequest::default())?
11021            .revision)
11022    }
11023
11024    /// One subscription's persisted catch-up state (see the §7.5 contract).
11025    pub fn subscription_catchup(
11026        &mut self,
11027        id: &str,
11028    ) -> Result<SubscriptionCatchup, QueryReadFailure> {
11029        let read = self.snapshot_read(&SnapshotReadRequest {
11030            subscriptions: vec![id.to_owned()],
11031            ..Default::default()
11032        })?;
11033        Ok(read
11034            .subscriptions
11035            .into_iter()
11036            .next()
11037            .expect("one requested catch-up"))
11038    }
11039
11040    /// One client commit's delivery status: pending, its retained outcome, or
11041    /// an explicit unknown (never held, or pruned by retention).
11042    pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
11043        let read = self.snapshot_read(&SnapshotReadRequest {
11044            commit_ids: vec![id.to_owned()],
11045            ..Default::default()
11046        })?;
11047        Ok(read
11048            .deliveries
11049            .into_iter()
11050            .next()
11051            .expect("one requested delivery"))
11052    }
11053
11054    /// Hosts report an owned failure, and the first success after it, to the
11055    /// owning core through [`SyncClient::record_query_read`] (§7.6).
11056    pub fn query_snapshot(
11057        &mut self,
11058        sql: &str,
11059        params: &[Value],
11060        coverage: &[WindowCoverage],
11061    ) -> Result<QuerySnapshot, QueryReadFailure> {
11062        snapshot_connection(self.connection()?, sql, params, coverage)
11063    }
11064}
11065
11066fn validate_authority_reads(
11067    schema: &ClientSchema,
11068    declarations: &[crate::api::AuthorityReadDeclaration],
11069) -> Result<(), String> {
11070    let mut seen = BTreeSet::new();
11071    for read in declarations {
11072        let table = schema
11073            .table(&read.table)
11074            .ok_or("client.authority_read_forbidden: authority table is not declared")?;
11075        let plain = |name: &str| {
11076            table.columns.iter().enumerate().any(|(i, c)| {
11077                c.name == name
11078                    && name != "authority_version"
11079                    && !name.starts_with("_sync")
11080                    && !table.encrypted_columns.iter().any(|e| e.index == i)
11081                    && matches!(
11082                        c.ty,
11083                        ssp2::segment::ColumnType::String
11084                            | ssp2::segment::ColumnType::Integer
11085                            | ssp2::segment::ColumnType::Float
11086                            | ssp2::segment::ColumnType::Boolean
11087                            | ssp2::segment::ColumnType::Json
11088                    )
11089            })
11090        };
11091        if !seen.insert(&read.table)
11092            || read.columns.is_empty()
11093            || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
11094            || !read.columns.contains(&table.primary_key)
11095            || !read.columns.iter().all(|c| plain(c))
11096            || read.scopes.is_empty()
11097            || !read.scopes.iter().all(|(v, values)| {
11098                table
11099                    .scope_column(v)
11100                    .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
11101                    && !values.is_empty()
11102                    && values.iter().all(|v| !v.is_empty() && v != "*")
11103            })
11104        {
11105            return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
11106        }
11107    }
11108    Ok(())
11109}
11110
11111// §7.4.2: every open and recreation checks before any persistent writes.
11112/// A marker read that hit a recognized SQLite storage failure keeps that
11113/// classification (BUSY/LOCKED stay retryable); only an unclassified read
11114/// failure is the invalid-marker `sync.local_corrupt`.
11115fn schema_marker_read_failure(error: rusqlite::Error) -> String {
11116    let failure = QueryReadFailure::from(error);
11117    if failure.code.is_some() {
11118        failure.to_string()
11119    } else {
11120        "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
11121    }
11122}
11123
11124/// §7.1: reject an invalid configured client limit before any storage is
11125/// opened or written. Zero would wedge the push queue and an out-of-range
11126/// value would differ per target; the TS core enforces the same bounds.
11127fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
11128    if limits.outcome_retention_max_entries == Some(0) {
11129        return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
11130    }
11131    if [
11132        limits.max_push_commits_per_request,
11133        limits.max_push_operations_per_request,
11134        limits.max_push_request_bytes,
11135    ]
11136    .into_iter()
11137    .flatten()
11138    .any(|value| value == 0 || value > u32::MAX as usize)
11139    {
11140        return Err(
11141            "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
11142        );
11143    }
11144    Ok(())
11145}
11146
11147fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
11148    let corrupt =
11149        || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
11150    let table_type: Option<String> = conn
11151        .query_row(
11152            "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
11153            [],
11154            |row| row.get(0),
11155        )
11156        .optional()
11157        .map_err(schema_marker_read_failure)?;
11158    let Some(table_type) = table_type else {
11159        return Ok(None);
11160    };
11161    if table_type != "table" {
11162        return Err(corrupt());
11163    }
11164    // §7.4.2: a duplicated marker cannot be resolved. `query_row` would take
11165    // whichever row SQLite returns first, and an older one would drive a
11166    // destructive reset over a newer replica.
11167    let mut statement = conn
11168        .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
11169        .map_err(schema_marker_read_failure)?;
11170    let mut rows = statement
11171        .query([LOCAL_SCHEMA_VERSION_KEY])
11172        .map_err(schema_marker_read_failure)?;
11173    let value: Option<String> = rows
11174        .next()
11175        .map_err(schema_marker_read_failure)?
11176        .map(|row| row.get(0))
11177        .transpose()
11178        .map_err(schema_marker_read_failure)?;
11179    if rows.next().map_err(schema_marker_read_failure)?.is_some() {
11180        return Err(corrupt());
11181    }
11182    let Some(value) = value else {
11183        let has_descriptor = conn
11184            .query_row(
11185                "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
11186                [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
11187                |_| Ok(()),
11188            )
11189            .optional()
11190            .map_err(schema_marker_read_failure)?
11191            .is_some();
11192        if has_descriptor {
11193            return Err(corrupt());
11194        }
11195        return Ok(None);
11196    };
11197    if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11198        || !value.bytes().all(|b| b.is_ascii_digit())
11199    {
11200        return Err(corrupt());
11201    }
11202    let version = value.parse::<i32>().map_err(|_| corrupt())?;
11203    if version > requested {
11204        return Err(
11205            "client.schema_downgrade: persisted local schema is newer than the requested schema"
11206                .to_owned(),
11207        );
11208    }
11209    Ok(Some(version))
11210}
11211
11212/// A file replica on a browser worker must recover a hot journal before any
11213/// statement reads it. `wasm_vfs` replaces the installed OPFS VFS's reserved
11214/// lock callback, so install it before the connection opens its file.
11215#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
11216fn prepare_wasm_replica(path: &str) -> Result<(), String> {
11217    if path.is_empty() || path == ":memory:" {
11218        return Ok(());
11219    }
11220    crate::wasm_vfs::disable_foreign_lock_check()
11221}
11222
11223#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
11224fn prepare_wasm_replica(_path: &str) -> Result<(), String> {
11225    Ok(())
11226}
11227
11228impl SyncClient {
11229    pub fn new_with_identity(
11230        client_id: Option<String>,
11231        schema_json: &Value,
11232        limits: ClientLimits,
11233    ) -> Result<Self, String> {
11234        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11235        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11236        Self::with_connection(resolved, schema_json, limits, conn)
11237    }
11238
11239    pub fn new(
11240        client_id: String,
11241        schema_json: &Value,
11242        limits: ClientLimits,
11243    ) -> Result<Self, String> {
11244        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11245        Self::with_connection(client_id, schema_json, limits, conn)
11246    }
11247
11248    /// Build a client backed by an on-disk SQLite database at `path` — the
11249    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
11250    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
11251    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
11252    /// of the command router's dependency set (the router only holds a path).
11253    pub fn open_path(
11254        client_id: String,
11255        schema_json: &Value,
11256        limits: ClientLimits,
11257        path: &str,
11258    ) -> Result<Self, String> {
11259        validate_client_limits(&limits)?;
11260        parse_schema_json(schema_json)?;
11261        prepare_wasm_replica(path)?;
11262        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11263        Self::with_connection(client_id, schema_json, limits, conn)
11264    }
11265
11266    /// Open a file replica and resolve its client id. Native targets require
11267    /// WAL for the independent snapshot reader; `wasm32-unknown-unknown`
11268    /// accepts the sqlite-wasm worker journal instead.
11269    pub fn open_path_with_identity(
11270        client_id: Option<String>,
11271        schema_json: &Value,
11272        limits: ClientLimits,
11273        path: &str,
11274    ) -> Result<Self, String> {
11275        Self::open_path_with_identity_journal(
11276            client_id,
11277            schema_json,
11278            limits,
11279            path,
11280            !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
11281        )
11282    }
11283
11284    fn open_path_with_identity_journal(
11285        client_id: Option<String>,
11286        schema_json: &Value,
11287        limits: ClientLimits,
11288        path: &str,
11289        require_wal: bool,
11290    ) -> Result<Self, String> {
11291        validate_client_limits(&limits)?;
11292        // §7.4.2: an invalid requested version is refused before the replica is
11293        // even created, so a rejected constructor leaves no file behind.
11294        parse_schema_json(schema_json)?;
11295        prepare_wasm_replica(path)?;
11296        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11297        // A file-backed native client uses an independent read connection for
11298        // latency-critical snapshots. WAL is SQLite's intended reader/writer
11299        // concurrency mode: a view read never holds a rollback-journal lock
11300        // that delays the client's next commit. The busy timeout must precede
11301        // this replica's own startup transaction; WAL is applied only after
11302        // startup succeeded, because a refused open must leave the replica's
11303        // journal mode and contents untouched (§7.4.2).
11304        conn.busy_timeout(std::time::Duration::from_millis(250))
11305            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
11306        // Durability is the caller's contract here, not the VFS default: a
11307        // rollback-journal commit needs FULL before it writes the database
11308        // pages, or a killed worker can leave a half-written image behind.
11309        // WAL below inherits the same setting.
11310        conn.pragma_update(None, "synchronous", "FULL")
11311            .map_err(|error| format!("configure db {path:?} synchronous: {error}"))?;
11312        let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
11313        // A browser worker's sqlite-wasm VFS has no WAL mode, and the wasm
11314        // host has no independent snapshot reader. The WAL requirement exists
11315        // for native file replicas, where it is the reader/writer boundary.
11316        if require_wal {
11317            let journal_mode: String = client
11318                .conn
11319                .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
11320                .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11321            if journal_mode != "wal" {
11322                return Err(
11323                    "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
11324                );
11325            }
11326        }
11327        Ok(client)
11328    }
11329
11330    /// Delete a file replica at `path`, including its `-journal`, `-wal`, and
11331    /// `-shm` sidecars, through the VFS that owns it. Use this after
11332    /// `client.storage_corrupt`: an OPFS SAH pool stores those files under
11333    /// opaque names, so deleting the pool directory behind SQLite bypasses the
11334    /// pool's slot accounting.
11335    ///
11336    /// Close the client and every [`FileQuerySnapshotReader`] on `path` first;
11337    /// a VFS cannot delete a file another handle still owns. The next open
11338    /// creates a fresh replica (new client id) and re-bootstraps from the
11339    /// server. A memory replica has nothing to delete and fails with
11340    /// `sync.invalid_request`.
11341    pub fn reset_replica(path: &str) -> Result<(), String> {
11342        if path.is_empty() || path == ":memory:" {
11343            return Err("sync.invalid_request: a memory replica has nothing to reset".to_owned());
11344        }
11345        // SAFETY: a null VFS name asks SQLite for its process-wide default VFS,
11346        // the same one `Connection::open(path)` above selects. `xDelete` is
11347        // that VFS's own function pointer, and `c_name` outlives the call.
11348        let vfs = unsafe { rusqlite::ffi::sqlite3_vfs_find(std::ptr::null()) };
11349        if vfs.is_null() {
11350            return Err("client.storage_io: no SQLite VFS is registered".to_owned());
11351        }
11352        let delete = unsafe { (*vfs).xDelete }
11353            .ok_or_else(|| "client.storage_io: the SQLite VFS cannot delete files".to_owned())?;
11354        for suffix in ["-wal", "-shm", "-journal", ""] {
11355            let file_name = format!("{path}{suffix}");
11356            let c_name = std::ffi::CString::new(file_name.as_str()).map_err(|_| {
11357                format!("sync.invalid_request: replica path {file_name:?} contains a NUL byte")
11358            })?;
11359            // SAFETY: `vfs` and its `xDelete` pointer come from the live
11360            // SQLite library; `c_name` is a valid NUL-terminated path.
11361            let code = unsafe { delete(vfs, c_name.as_ptr(), 1) };
11362            if code != rusqlite::ffi::SQLITE_OK {
11363                return Err(format!(
11364                    "client.storage_io: deleting replica file {file_name:?} failed with SQLite code {code}"
11365                ));
11366            }
11367        }
11368        Ok(())
11369    }
11370
11371    #[must_use]
11372    pub fn client_id(&self) -> &str {
11373        &self.client_id
11374    }
11375
11376    /// Rotate the caller's client id (§1.5) and return the new one. Use it
11377    /// after a `sync.invalid_client_id` refusal (`recommendedAction:
11378    /// resetClientId`): the next round carries the new id. A live realtime
11379    /// socket is closed so the next connection registers with the new id.
11380    /// The replica and its outbox stay in place; wipe them yourself when the
11381    /// stored data belongs to the account that signed out.
11382    pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> String {
11383        self.disconnect_realtime(transport);
11384        let client_id = uuid::Uuid::new_v4().to_string();
11385        self.client_id.clone_from(&client_id);
11386        self.set_meta(CLIENT_ID_KEY, &client_id);
11387        self.round_failure = None;
11388        client_id
11389    }
11390
11391    /// Build a client over a caller-supplied rusqlite connection — the seam a
11392    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
11393    /// with an on-disk database (`Connection::open(path)`) rather than the
11394    /// default `:memory:`. The connection MUST be fresh (no pre-existing
11395    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
11396    /// the same file across process restarts reuses the persisted rows.
11397    pub fn with_connection(
11398        client_id: String,
11399        schema_json: &Value,
11400        limits: ClientLimits,
11401        conn: Connection,
11402    ) -> Result<Self, String> {
11403        Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
11404    }
11405
11406    /// [`Self::with_connection`] for `open_path_with_identity`: no requested id
11407    /// means a durable replica's persisted `clientId` wins over a fresh one.
11408    fn with_connection_identity(
11409        client_id: Option<String>,
11410        schema_json: &Value,
11411        limits: ClientLimits,
11412        conn: Connection,
11413    ) -> Result<Self, String> {
11414        validate_client_limits(&limits)?;
11415        let schema = parse_schema_json(schema_json)?;
11416        validate_authority_reads(&schema, &limits.authority_reads)?;
11417        // RFC 0005 D8: resolve the previous-version config before the opening
11418        // reset runs — the capture happens inside it. Bad bounds fail loud.
11419        let previous_version = match limits.previous_version_context {
11420            Some(config) => {
11421                config.validate()?;
11422                Some(config)
11423            }
11424            None => None,
11425        };
11426        let mut client = SyncClient {
11427            #[cfg(feature = "bench-internals")]
11428            benchmark_phases: Recorder::default(),
11429            conn,
11430            schema,
11431            client_id: client_id
11432                .clone()
11433                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
11434            limits,
11435            subs: Vec::new(),
11436            outbox: Vec::new(),
11437            failed_commits: Vec::new(),
11438            retain_failed_commits: false,
11439            conflicts: Vec::new(),
11440            rejections: Vec::new(),
11441            schema_floor: None,
11442            lease_state: None,
11443            stopped: false,
11444            upgrading: false,
11445            sync_needed: false,
11446            realtime_policy: RealtimePolicy::Optional,
11447            realtime_state: RealtimeState::Disconnected,
11448            realtime_reason_code: None,
11449            realtime_retry_delay_ms: None,
11450            presence: HashMap::new(),
11451            now_ms: None,
11452            encryption: crate::values::EncryptionConfig::default(),
11453            security_preflight: false,
11454            transport_enabled: true,
11455            previous_version,
11456            insert_sql: RefCell::new(HashMap::new()),
11457            overlay_dirty: OverlayDirty::clean(),
11458            storage_failure: RefCell::new(None),
11459            #[cfg(test)]
11460            overlay_rebuild_count: Cell::new(0),
11461            #[cfg(test)]
11462            acknowledged_replay_count: Cell::new(0),
11463            #[cfg(test)]
11464            outcome_prune_count: Cell::new(0),
11465            progress: ProgressObserver::default(),
11466            change_queue: VecDeque::new(),
11467            sync_intent_queue: VecDeque::new(),
11468            retry_delay_ms: 250,
11469            round_retry_delay_ms: None,
11470            round_failure: None,
11471            active_round: None,
11472            last_round: None,
11473            last_change: None,
11474            query_failures: Vec::new(),
11475            coalesced: None,
11476            coalesced_failure: None,
11477        };
11478        // The row write path leans on the prepared-statement cache (two
11479        // insert statements per synced table, plus the bookkeeping
11480        // statements); size it so a multi-table schema never thrashes.
11481        client
11482            .conn
11483            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
11484        // §7.4.2: one write transaction (SQLite `BEGIN IMMEDIATE`, a reserved
11485        // writer) covers the marker read, the identity check, and every
11486        // bookkeeping/schema write, so a concurrent open that upgraded the
11487        // replica cannot interleave between the guard and the writes, and a
11488        // refusal leaves the replica unchanged.
11489        client
11490            .conn
11491            .execute_batch("BEGIN IMMEDIATE")
11492            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11493        let startup = (|| -> Result<(), String> {
11494            // The marker guard runs inside the write transaction, so the version
11495            // it validates is the version every write below commits under.
11496            let marker = read_local_schema_version(&client.conn, client.schema.version)?;
11497            // New app indexes may reference columns that only exist after a
11498            // version-bump reset.
11499            client.create_bookkeeping_tables()?;
11500            match client.get_meta(CLIENT_ID_KEY) {
11501                Some(existing) => match client_id {
11502                    Some(requested) if existing != requested => {
11503                        return Err(format!(
11504                            "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
11505                        ))
11506                    }
11507                    _ => client.client_id = existing,
11508                },
11509                None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
11510            }
11511            client.restore_persisted_state()?;
11512            client.failed_commits = client.load_failed_commits()?;
11513            match marker {
11514                None => {
11515                    client.create_synced_tables()?;
11516                    client.persist_schema_version()?;
11517                    client.save_subscription_scope_schema();
11518                }
11519                Some(version) if version == client.schema.version => {
11520                    client.create_synced_tables()?;
11521                    // RFC 0005 D1: the same-version open backfills the descriptor
11522                    // for a database first opened by an unaware binary, so the
11523                    // NEXT bump can capture without needing a schema bump first.
11524                    set_local_schema_descriptor(&client.conn, &client.schema)
11525                        .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11526                    client.save_subscription_scope_schema();
11527                }
11528                Some(_) => client.run_schema_reset()?,
11529            }
11530            // RFC 0005 D9: the reset sweep cannot see a container whose capture
11531            // committed in its OWN file while the replica's savepoint never
11532            // released — a crash at that seam leaves a container beside a matching
11533            // OLD marker, and the next open at that version runs no reset. Discard
11534            // it before anything can read it. No-op when no container exists.
11535            client.reconcile_previous_version_at_boot()?;
11536            client.clear_satisfied_persisted_schema_floor();
11537            client.prune_unknown_subscriptions(false)?;
11538            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
11539            // the full startup/reset reconciliation boundary, inside this open
11540            // transaction and after the schema and identity guards, so a commit
11541            // an earlier version accepted is dropped with durable rejection
11542            // evidence before any replay. A table the current schema removed
11543            // keeps the §7.4.4 send-time classification, and an unresolved
11544            // encryption key stays with the send seam because hosts configure
11545            // keys after open.
11546            client.drop_unencodable_outbox(false, None)?;
11547            if marker == Some(client.schema.version)
11548                && (!client.outbox.is_empty()
11549                    || !client.failed_commits.is_empty()
11550                    || client.has_acknowledged_rows())
11551            {
11552                // Reconstruct the visible optimistic overlay from the durable
11553                // base plus outbox instead of trusting a process-interrupted
11554                // mirror.
11555                client.overlay_dirty.set(true);
11556                client.rebuild_overlay()?;
11557            }
11558            // Every persisted active subscription needs one catch-up pull on
11559            // open: realtime only covers changes after connection, while an
11560            // idempotent setWindow correctly creates no fresh command effect.
11561            // Pending outbox work has the same restart requirement. The core
11562            // owns this intent so native hosts never poll or require an
11563            // application-issued sync().
11564            client.enqueue_startup_sync_if_needed()?;
11565            Ok(())
11566        })();
11567        match startup {
11568            Ok(()) => match client.conn.execute_batch("COMMIT") {
11569                Ok(()) => Ok(client),
11570                Err(error) => {
11571                    let failure = Self::sqlite_failure(&client.storage_failure, error);
11572                    if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
11573                        // Keep a failed rollback's typed storage classification.
11574                        let _ = Self::sqlite_failure(&client.storage_failure, rollback);
11575                    }
11576                    Err(failure)
11577                }
11578            },
11579            Err(error) => {
11580                if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
11581                    // Keep a failed rollback's typed storage classification.
11582                    let _ = Self::sqlite_failure(&client.storage_failure, rollback);
11583                }
11584                Err(error)
11585            }
11586        }
11587    }
11588
11589    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
11590    /// against this instead of system time (conformance virtual clock).
11591    pub fn set_now_ms(&mut self, now_ms: i64) {
11592        self.now_ms = Some(now_ms);
11593    }
11594
11595    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
11596    /// The command router parses these from the `create` command's
11597    /// `encryption` config (keys as `{$bytes: hex}`).
11598    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
11599        self.encryption = encryption;
11600    }
11601
11602    #[must_use]
11603    pub fn security_lifecycle(&self) -> &'static str {
11604        if self.security_preflight {
11605            "preflight"
11606        } else {
11607            "active"
11608        }
11609    }
11610
11611    #[must_use]
11612    pub fn security_preflight(&self) -> bool {
11613        self.security_preflight
11614    }
11615
11616    /// Host-owned, non-persisted network gate. Local operations remain available.
11617    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
11618        if self.transport_enabled != enabled {
11619            self.transport_enabled = enabled;
11620            self.sync_intent_queue.clear();
11621            self.sync_intent_queue
11622                .push_back(if enabled && !self.security_preflight {
11623                    SyncIntent::Interactive
11624                } else {
11625                    SyncIntent::None
11626                });
11627        }
11628        // A captured reply keeps its normal apply/revocation semantics.
11629        if !enabled && self.active_round.is_none() {
11630            self.disconnect_realtime(transport);
11631        }
11632    }
11633
11634    pub fn transport_enabled(&self) -> bool {
11635        self.transport_enabled
11636    }
11637
11638    /// Quarantine this replica: enter the fail-closed gate, release all
11639    /// core-owned key material, and record the gate in the database so
11640    /// reopening re-enters preflight until `activate_security` clears it.
11641    ///
11642    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
11643    /// which must NOT leave that durable mark.
11644    pub fn begin_security_preflight(&mut self) {
11645        self.seal_security_on_teardown();
11646        // Persist the quarantine so it survives handle teardown and restart:
11647        // reopening this replica re-enters preflight until activation clears it.
11648        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
11649    }
11650
11651    /// Release core-owned key material as a host tears the client down,
11652    /// WITHOUT recording a quarantine.
11653    ///
11654    /// Shutting an activated client down is not a quarantine event. Persisting
11655    /// the gate here marks every cleanly closed replica as pending, and the
11656    /// next plain `create` is then refused permanently: the reopen path
11657    /// restores the flag from the marker and the create guard rejects it. The
11658    /// in-memory flag still closes the gate for anything still holding this
11659    /// instance, which is all a teardown barrier needs.
11660    pub fn seal_security_on_teardown(&mut self) {
11661        self.cancel_sync_round();
11662        self.security_preflight = true;
11663        self.encryption = crate::values::EncryptionConfig::default();
11664        self.sync_intent_queue.clear();
11665    }
11666
11667    /// Install the post-authentication keyring and release the host loop.
11668    pub fn activate_security(
11669        &mut self,
11670        encryption: crate::values::EncryptionConfig,
11671    ) -> Result<(), String> {
11672        if !self.security_preflight {
11673            return Err(
11674                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
11675            );
11676        }
11677        self.enqueue_startup_sync_if_needed()?;
11678        self.encryption = encryption;
11679        self.security_preflight = false;
11680        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
11681        Ok(())
11682    }
11683
11684    fn clock_now_ms(&self) -> i64 {
11685        self.now_ms.unwrap_or_else(crate::wall_clock_ms)
11686    }
11687
11688    fn create_bookkeeping_tables(&self) -> Result<(), String> {
11689        // Durable client bookkeeping (outbox + subscription + meta).
11690        self.conn
11691            .execute_batch(
11692                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
11693                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
11694                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
11695                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
11696                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
11697                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
11698                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
11699                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
11700                   PRIMARY KEY(commit_id, idx));
11701                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
11702                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
11703                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
11704                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
11705                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
11706                   client_commit_id TEXT NOT NULL UNIQUE,
11707                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
11708                   recorded_at_ms INTEGER NOT NULL,
11709                   results_json TEXT NOT NULL,
11710                   operations_json TEXT,
11711                   resolution TEXT NOT NULL DEFAULT 'active'
11712                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
11713                   resolved_at_ms INTEGER,
11714                   replacement_client_commit_id TEXT);
11715                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
11716                   ON _syncular_commit_outcomes(resolution, seq);
11717                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
11718                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
11719                 CREATE TABLE IF NOT EXISTS _syncular_meta (
11720                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
11721                 CREATE TABLE IF NOT EXISTS _syncular_windows (
11722                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
11723                   PRIMARY KEY (base, unit));
11724                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
11725                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
11726                   effective_scopes TEXT NOT NULL);
11727                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
11728                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
11729                   PRIMARY KEY(commit_id, blob_id));
11730                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
11731                   ON _syncular_blob_commit_refs(blob_id);",
11732            )
11733            .map_err(|e| e.to_string())?;
11734        // Migrate an outcome journal created before failed aggregate
11735        // envelopes were retained. Historical rows intentionally stay NULL.
11736        let _ = self
11737            .conn
11738            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
11739        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
11740            self.set_meta(LOCAL_REVISION_KEY, "0");
11741        }
11742        // §5.9.7 blob cache. Mutable upload state stays off the body row so
11743        // completing a large upload does not rewrite its BLOB record.
11744        if self.schema_has_blobs() {
11745            let exists = self
11746                .conn
11747                .query_row(
11748                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
11749                    [],
11750                    |row| row.get::<_, bool>(0),
11751                )
11752                .map_err(|e| e.to_string())?;
11753            if exists {
11754                let mut statement = self
11755                    .conn
11756                    .prepare("PRAGMA table_info(_syncular_blobs)")
11757                    .map_err(|e| e.to_string())?;
11758                let mut columns = statement
11759                    .query_map([], |row| row.get::<_, String>(1))
11760                    .map_err(|e| e.to_string())?
11761                    .collect::<Result<Vec<_>, _>>()
11762                    .map_err(|e| e.to_string())?;
11763                columns.sort();
11764                let mut expected = vec![
11765                    "blob_id",
11766                    "byte_length",
11767                    "bytes",
11768                    "created_at_ms",
11769                    "media_type",
11770                ];
11771                expected.sort_unstable();
11772                if columns != expected {
11773                    return Err(
11774                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
11775                    );
11776                }
11777            } else {
11778                self.conn
11779                    .execute_batch(
11780                        "CREATE TABLE _syncular_blobs(
11781                           blob_id TEXT PRIMARY KEY,
11782                           bytes BLOB NOT NULL,
11783                           byte_length INTEGER NOT NULL,
11784                           media_type TEXT,
11785                           created_at_ms INTEGER NOT NULL);",
11786                    )
11787                    .map_err(|e| e.to_string())?;
11788            }
11789            self.conn
11790                .execute_batch(
11791                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
11792                       blob_id TEXT PRIMARY KEY,
11793                       media_type TEXT,
11794                       created_at_ms INTEGER NOT NULL);",
11795                )
11796                .map_err(|e| e.to_string())?;
11797        }
11798        Ok(())
11799    }
11800
11801    /// True iff any synced table declares a `blob_ref` column (§5.9).
11802    fn schema_has_blobs(&self) -> bool {
11803        self.schema
11804            .tables
11805            .iter()
11806            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
11807    }
11808
11809    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
11810
11811    fn get_meta(&self, key: &str) -> Option<String> {
11812        meta_get(&self.conn, key)
11813    }
11814
11815    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
11816        self.conn
11817            .query_row(
11818                "SELECT value FROM _syncular_meta WHERE key = ?1",
11819                rusqlite::params![key],
11820                |row| row.get::<_, String>(0),
11821            )
11822            .optional()
11823            .map_err(|_| {
11824                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
11825            })
11826    }
11827
11828    // The caller holds the startup/reset transaction. Either both schema
11829    // records persist or the enclosing operation fails and rolls back.
11830    fn persist_schema_version(&self) -> Result<(), String> {
11831        self.conn
11832            .execute(
11833                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11834                rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
11835            )
11836            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11837        set_local_schema_descriptor(&self.conn, &self.schema)
11838            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11839    }
11840
11841    fn set_meta(&self, key: &str, value: &str) {
11842        meta_set(&self.conn, key, value);
11843    }
11844
11845    fn delete_meta(&self, key: &str) {
11846        meta_delete(&self.conn, key);
11847    }
11848
11849    /// §7.1: the durable outbox in FIFO order, in schema-agnostic local form.
11850    fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
11851        let mut stmt = self
11852            .conn
11853            .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
11854            .map_err(|error| error.to_string())?;
11855        let rows = stmt
11856            .query_map([], |row| {
11857                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11858            })
11859            .map_err(|error| error.to_string())?;
11860        let mut commits = Vec::new();
11861        for row in rows {
11862            let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
11863            let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
11864                format!("invalid persisted outbox {client_commit_id:?}: {error}")
11865            })?;
11866            let mut ops = Vec::with_capacity(entries.len());
11867            for entry in entries {
11868                let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
11869                ops.push(OutboxOp {
11870                    upsert: op == "upsert",
11871                    table: entry
11872                        .get("table")
11873                        .and_then(Value::as_str)
11874                        .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
11875                        .to_owned(),
11876                    row_id: entry
11877                        .get("rowId")
11878                        .and_then(Value::as_str)
11879                        .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
11880                        .to_owned(),
11881                    base_version: entry.get("baseVersion").and_then(Value::as_i64),
11882                    // A legacy entry's `changedFields` key is ignored:
11883                    // the values map is the presence set (§6.1).
11884                    values: entry.get("values").and_then(Value::as_object).cloned(),
11885                });
11886            }
11887            commits.push(OutboxCommit {
11888                client_commit_id,
11889                ops,
11890            });
11891        }
11892        Ok(commits)
11893    }
11894
11895    fn restore_persisted_state(&mut self) -> Result<(), String> {
11896        self.subs = {
11897            let mut stmt = self
11898                .conn
11899                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
11900                .map_err(|error| error.to_string())?;
11901            let rows = stmt
11902                .query_map([], |row| {
11903                    Ok((
11904                        row.get::<_, String>(0)?,
11905                        row.get::<_, String>(1)?,
11906                        row.get::<_, String>(2)?,
11907                    ))
11908                })
11909                .map_err(|error| error.to_string())?;
11910            let mut subscriptions = Vec::new();
11911            for row in rows {
11912                let (id, table, raw) = row.map_err(|error| error.to_string())?;
11913                let state: Value = serde_json::from_str(&raw)
11914                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
11915                let requested = json_to_scope_map(
11916                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
11917                )?;
11918                let effective = state
11919                    .get("effectiveScopes")
11920                    .filter(|value| !value.is_null())
11921                    .map(json_to_scope_map)
11922                    .transpose()?;
11923                subscriptions.push(Subscription {
11924                    id,
11925                    table,
11926                    requested,
11927                    params: state
11928                        .get("params")
11929                        .and_then(Value::as_str)
11930                        .map(str::to_owned),
11931                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
11932                    bootstrap_state: state
11933                        .get("bootstrapState")
11934                        .and_then(Value::as_str)
11935                        .map(str::to_owned),
11936                    state: SubState::parse(
11937                        state
11938                            .get("status")
11939                            .and_then(Value::as_str)
11940                            .unwrap_or("active"),
11941                    ),
11942                    reason_code: state
11943                        .get("reasonCode")
11944                        .and_then(Value::as_str)
11945                        .map(str::to_owned),
11946                    effective,
11947                    synced_once: state
11948                        .get("syncedOnce")
11949                        .and_then(Value::as_bool)
11950                        .unwrap_or(false),
11951                });
11952            }
11953            subscriptions
11954        };
11955
11956        self.outbox = self.load_outbox()?;
11957
11958        self.prune_commit_outcomes()?;
11959        let active = self.commit_outcomes(CommitOutcomeQuery {
11960            active_only: true,
11961            ..CommitOutcomeQuery::default()
11962        })?;
11963        self.conflicts = active
11964            .iter()
11965            .flat_map(|outcome| outcome.results.iter())
11966            .filter_map(|result| match result {
11967                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
11968                _ => None,
11969            })
11970            .collect();
11971        self.rejections = active
11972            .iter()
11973            .flat_map(|outcome| outcome.results.iter())
11974            .filter_map(|result| match result {
11975                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
11976                _ => None,
11977            })
11978            .collect();
11979
11980        self.lease_state = self
11981            .get_meta(LEASE_STATE_KEY)
11982            .map(|raw| serde_json::from_str(&raw))
11983            .transpose()
11984            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
11985        self.schema_floor = self
11986            .get_meta(SCHEMA_FLOOR_KEY)
11987            .map(|raw| serde_json::from_str(&raw))
11988            .transpose()
11989            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
11990        self.stopped = self.schema_floor.is_some();
11991        // A replica left in unactivated preflight reopens gated: the quarantine
11992        // decision persists with the data across handle teardown and restart.
11993        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
11994            self.security_preflight = true;
11995        }
11996        Ok(())
11997    }
11998
11999    fn save_subscription_scope_schema(&self) {
12000        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
12001            .schema
12002            .tables
12003            .iter()
12004            .map(|table| {
12005                (
12006                    table.name.clone(),
12007                    table
12008                        .scope_variables
12009                        .iter()
12010                        .map(|scope| {
12011                            (
12012                                scope.variable.clone(),
12013                                [scope.prefix.clone(), scope.column.clone()],
12014                            )
12015                        })
12016                        .collect(),
12017                )
12018            })
12019            .collect();
12020        self.set_meta(
12021            "subscriptionScopeSchema",
12022            &serde_json::to_string(&declarations).expect("scope schema serialization"),
12023        );
12024    }
12025
12026    /// Remove registrations with incompatible table/key/prefix/column meaning.
12027    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
12028        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
12029            self.get_meta("subscriptionScopeSchema")
12030                .map(|raw| serde_json::from_str(&raw))
12031                .transpose()
12032                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
12033        } else {
12034            None
12035        };
12036        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
12037            let Some(table) = self.schema.table(table_name) else {
12038                return false;
12039            };
12040            if requested
12041                .iter()
12042                .any(|(variable, _)| table.scope_column(variable).is_none())
12043            {
12044                return false;
12045            }
12046            if !bump {
12047                return true;
12048            }
12049            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
12050                return false;
12051            };
12052            let variables: BTreeSet<_> = if requested.is_empty() {
12053                old.keys()
12054                    .cloned()
12055                    .chain(
12056                        table
12057                            .scope_variables
12058                            .iter()
12059                            .map(|scope| scope.variable.clone()),
12060                    )
12061                    .collect()
12062            } else {
12063                requested
12064                    .iter()
12065                    .map(|(variable, _)| variable.clone())
12066                    .collect()
12067            };
12068            variables.iter().all(|variable| {
12069                let current = table
12070                    .scope_variables
12071                    .iter()
12072                    .find(|scope| &scope.variable == variable);
12073                match (old.get(variable), current) {
12074                    (Some(old), Some(current)) => {
12075                        old == &[current.prefix.clone(), current.column.clone()]
12076                    }
12077                    _ => false,
12078                }
12079            })
12080        };
12081        let mut stale_ids: BTreeSet<_> = self
12082            .subs
12083            .iter()
12084            .filter(|sub| !compatible(&sub.table, &sub.requested))
12085            .map(|sub| sub.id.clone())
12086            .collect();
12087        for (id, table, effective) in self.load_pending_evictions()? {
12088            if !compatible(&table, &effective) {
12089                stale_ids.insert(id);
12090            }
12091        }
12092        for id in &stale_ids {
12093            for sql in [
12094                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
12095                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
12096                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
12097            ] {
12098                self.conn
12099                    .execute(sql, rusqlite::params![id])
12100                    .map_err(|error| error.to_string())?;
12101            }
12102        }
12103        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
12104        Ok(())
12105    }
12106
12107    #[must_use]
12108    pub fn local_revision(&self) -> u64 {
12109        self.get_meta(LOCAL_REVISION_KEY)
12110            .and_then(|value| value.parse().ok())
12111            .unwrap_or(0)
12112    }
12113
12114    /// §7.5: resolve a snapshot read on the mutable owner connection through
12115    /// the same primitive the read-only sidecar uses, so the command surface
12116    /// and the file sidecar return one revision, rows, coverage, catch-up, and
12117    /// delivery shape. The structured [`QueryReadFailure`] is preserved; an
12118    /// `owner` records its failure (and its next success) in diagnostics.
12119    pub fn snapshot_read(
12120        &mut self,
12121        request: &SnapshotReadRequest<'_>,
12122        owner: Option<QueryOwner<'_>>,
12123    ) -> Result<SnapshotRead, QueryReadFailure> {
12124        let result = snapshot_read_connection(&self.conn, request);
12125        if let Some(owner) = owner {
12126            self.record_query_read(owner, result.as_ref().err());
12127        }
12128        result
12129    }
12130
12131    #[must_use]
12132    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
12133        SyncStatusSnapshot {
12134            current_schema_version: self.schema.version,
12135            outbox: self.outbox.len(),
12136            upgrading: self.upgrading,
12137            lease_state: self.lease_state.clone(),
12138            schema_floor: self.schema_floor.clone(),
12139            sync_needed: self.sync_needed,
12140            previous_version_context: self.previous_version_status(),
12141        }
12142    }
12143
12144    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
12145    /// rollback precondition), read without creating the file.
12146    fn previous_version_status(&self) -> PreviousVersionStatus {
12147        let Some(replica_path) = self.previous_version_replica_path() else {
12148            return PreviousVersionStatus {
12149                present: false,
12150                created_at_ms: None,
12151            };
12152        };
12153        match crate::previous_version::read_previous_version_record(&replica_path) {
12154            Ok(Some(record)) => PreviousVersionStatus {
12155                present: true,
12156                created_at_ms: Some(record.created_at_ms),
12157            },
12158            _ => PreviousVersionStatus {
12159                present: std::path::Path::new(
12160                    &crate::previous_version::previous_version_container_path(&replica_path),
12161                )
12162                .exists(),
12163                created_at_ms: None,
12164            },
12165        }
12166    }
12167
12168    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
12169    /// is always `previousVersion`; the container never makes
12170    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
12171    /// revocation, coverage completion and the TTL DISCARD the container and
12172    /// close the read with their own reason.
12173    pub fn previous_version_snapshot(
12174        &self,
12175        spec: &PreviousVersionReadSpec,
12176    ) -> Result<PreviousVersionSnapshot, String> {
12177        let now_ms = self.clock_now_ms();
12178        crate::previous_version::previous_version_snapshot(
12179            &self.conn,
12180            self.previous_version_replica_path().as_deref(),
12181            self.previous_version.as_ref(),
12182            PreviousVersionLifecycle {
12183                lease_inactive: self.previous_version_lease_inactive(now_ms),
12184                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
12185                coverage_complete: self.previous_version_coverage_complete(),
12186            },
12187            now_ms,
12188            self.schema.version,
12189            spec,
12190        )
12191    }
12192
12193    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
12194    #[must_use]
12195    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
12196        crate::previous_version::stored_previous_version_audit(&self.conn)
12197    }
12198
12199    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
12200    /// and both metadata records, and report whether anything was present.
12201    /// Idempotent: absence is a successful no-op.
12202    pub fn previous_version_discard(
12203        &self,
12204    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
12205        self.drop_previous_version()
12206    }
12207
12208    /// The module's discard has no failure path (it removes the file even when
12209    /// the container cannot be opened), so the only error a caller could ever
12210    /// see is a bug; report absence rather than claiming a discard happened.
12211    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
12212        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
12213            present: false,
12214            discarded: false,
12215        };
12216        let Some(replica_path) = self.previous_version_replica_path() else {
12217            return absent;
12218        };
12219        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
12220            .unwrap_or(absent)
12221    }
12222
12223    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
12224    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
12225        let Some(lease) = &self.lease_state else {
12226            return false;
12227        };
12228        if lease.error_code.is_some() {
12229            return true;
12230        }
12231        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
12232    }
12233
12234    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
12235    /// has `cursor >= 0` and no resume token. An empty active set is not
12236    /// completion (mirrors the TS predicate exactly).
12237    fn previous_version_coverage_complete(&self) -> bool {
12238        let active = self
12239            .subs
12240            .iter()
12241            .filter(|sub| sub.state == SubState::Active)
12242            .collect::<Vec<_>>();
12243        if active.is_empty() {
12244            return false;
12245        }
12246        active
12247            .iter()
12248            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
12249    }
12250
12251    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
12252    /// apply. A container that no longer has a reason to exist is discarded so
12253    /// the rows do not outlive the window they were kept for.
12254    fn previous_version_lifetime_check(&self) {
12255        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
12256            return;
12257        };
12258        let now_ms = self.clock_now_ms();
12259        if self.previous_version_lease_inactive(now_ms) {
12260            let _ = self.drop_previous_version();
12261            return;
12262        }
12263        let Some(replica_path) = self.previous_version_replica_path() else {
12264            return;
12265        };
12266        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
12267        else {
12268            return;
12269        };
12270        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
12271            let _ = self.drop_previous_version();
12272            return;
12273        }
12274        if now_ms - record.created_at_ms > config.max_age_ms {
12275            let _ = self.drop_previous_version();
12276            return;
12277        }
12278        if self.previous_version_coverage_complete() {
12279            let _ = self.drop_previous_version();
12280        }
12281    }
12282
12283    pub fn diagnostics_snapshot(
12284        &self,
12285        request: &ClientDiagnosticsRequest,
12286    ) -> Result<ClientDiagnosticsSnapshot, String> {
12287        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12288            return Err(format!(
12289                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12290            ));
12291        }
12292        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
12293        for sub in &self.subs {
12294            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
12295            let complete =
12296                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
12297            let state = match sub.state {
12298                SubState::Revoked => "revoked",
12299                SubState::Failed => "failed",
12300                SubState::Active if reset => "reset",
12301                SubState::Active if complete => "complete",
12302                SubState::Active => "bootstrapping",
12303            };
12304            subscriptions.insert(
12305                sub.id.clone(),
12306                DiagnosticSubscription {
12307                    id: sub.id.clone(),
12308                    table: sub.table.clone(),
12309                    state: state.to_owned(),
12310                    complete,
12311                    cursor: Some(sub.cursor),
12312                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
12313                },
12314            );
12315        }
12316        for expected in &request.expected_subscriptions {
12317            if expected.id.is_empty() || expected.table.is_empty() {
12318                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
12319            }
12320            if subscriptions
12321                .get(&expected.id)
12322                .is_some_and(|registered| registered.table != expected.table)
12323            {
12324                subscriptions.insert(
12325                    expected.id.clone(),
12326                    DiagnosticSubscription {
12327                        id: expected.id.clone(),
12328                        table: expected.table.clone(),
12329                        state: "failed".to_owned(),
12330                        complete: false,
12331                        cursor: None,
12332                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
12333                    },
12334                );
12335            } else {
12336                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
12337                    DiagnosticSubscription {
12338                        id: expected.id.clone(),
12339                        table: expected.table.clone(),
12340                        state: "unregistered".to_owned(),
12341                        complete: false,
12342                        cursor: None,
12343                        reason_code: None,
12344                    }
12345                });
12346            }
12347        }
12348        let mut ordered_subscriptions = Vec::new();
12349        let mut included = BTreeSet::new();
12350        for expected in &request.expected_subscriptions {
12351            if included.insert(expected.id.clone()) {
12352                if let Some(subscription) = subscriptions.get(&expected.id) {
12353                    ordered_subscriptions.push(subscription.clone());
12354                }
12355            }
12356        }
12357        for (id, subscription) in &subscriptions {
12358            if included.insert(id.clone()) {
12359                ordered_subscriptions.push(subscription.clone());
12360            }
12361        }
12362        let subscriptions_truncated =
12363            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
12364        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
12365        let captured_at_ms = self.clock_now_ms();
12366        let lease = if let Some(error_code) = self
12367            .lease_state
12368            .as_ref()
12369            .and_then(|state| state.error_code.clone())
12370        {
12371            ClientDiagnosticsLease {
12372                state: "stopped".to_owned(),
12373                expires_at_ms: self
12374                    .lease_state
12375                    .as_ref()
12376                    .and_then(|state| state.expires_at_ms),
12377                error_code: Some(Self::diagnostic_code(&error_code)),
12378            }
12379        } else if let Some(expires_at_ms) = self
12380            .lease_state
12381            .as_ref()
12382            .and_then(|state| state.expires_at_ms)
12383        {
12384            ClientDiagnosticsLease {
12385                state: if expires_at_ms <= captured_at_ms {
12386                    "expired".to_owned()
12387                } else {
12388                    "active".to_owned()
12389                },
12390                expires_at_ms: Some(expires_at_ms),
12391                error_code: None,
12392            }
12393        } else {
12394            ClientDiagnosticsLease {
12395                state: "none".to_owned(),
12396                expires_at_ms: None,
12397                error_code: None,
12398            }
12399        };
12400        let connectivity = match self.last_round.as_ref() {
12401            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
12402            Some(round) if round.status == "succeeded" => "online",
12403            Some(round)
12404                if round.status == "failed"
12405                    && round
12406                        .error_code
12407                        .as_deref()
12408                        .is_some_and(Self::retryable_transport_code) =>
12409            {
12410                "offline"
12411            }
12412            _ => "unknown",
12413        };
12414        Ok(ClientDiagnosticsSnapshot {
12415            version: CLIENT_DIAGNOSTICS_VERSION,
12416            captured_at_ms,
12417            host: ClientDiagnosticsHost {
12418                kind: "direct".to_owned(),
12419                role: "single".to_owned(),
12420                connectivity: connectivity.to_owned(),
12421                realtime: self.realtime_state,
12422                realtime_policy: self.realtime_policy,
12423                realtime_reason_code: self.realtime_reason_code.clone(),
12424                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
12425            },
12426            security_lifecycle: self.security_lifecycle().to_owned(),
12427            schema: ClientDiagnosticsSchema {
12428                current_version: self.schema.version,
12429                upgrading: self.upgrading,
12430                required_version: self
12431                    .schema_floor
12432                    .as_ref()
12433                    .and_then(|floor| floor.required_schema_version),
12434                latest_version: self
12435                    .schema_floor
12436                    .as_ref()
12437                    .and_then(|floor| floor.latest_schema_version),
12438            },
12439            replica: ClientDiagnosticsReplica {
12440                local_revision: self.local_revision().to_string(),
12441                sync_needed: self.sync_needed,
12442                pending_outbox: self.outbox.len(),
12443            },
12444            lease,
12445            subscriptions: ordered_subscriptions,
12446            subscriptions_truncated,
12447            last_round: self.last_round.clone(),
12448            last_change: self.last_change.clone(),
12449            storage: self.diagnostics_storage(),
12450            query_failures: self.query_failures.clone(),
12451        })
12452    }
12453
12454    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
12455        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
12456            let page_count: i64 = self
12457                .conn
12458                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
12459            let page_size: i64 = self
12460                .conn
12461                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
12462            // Reuse compiled aggregates; every invocation still reads current storage.
12463            let outbox_bytes: i64 = self
12464                .conn
12465                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
12466                .query_row([], |row| row.get(0))?;
12467            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
12468                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
12469            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
12470            let blob_bytes = if self.schema_has_blobs() {
12471                self.conn
12472                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
12473                    .query_row([], |row| row.get(0))?
12474            } else {
12475                0
12476            };
12477            let pressure = self
12478                .limits
12479                .blob_cache_max_bytes
12480                .is_some_and(|limit| blob_bytes > limit);
12481            Ok(ClientDiagnosticsStorage {
12482                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
12483                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
12484                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
12485                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
12486                retained_outcome_entries: Some(outcome_entries.max(0)),
12487                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
12488                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
12489            })
12490        };
12491        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
12492            status: "unreadable".to_owned(),
12493            database_bytes_approx: None,
12494            pending_outbox_bytes_approx: None,
12495            retained_outcome_bytes_approx: None,
12496            retained_outcome_entries: None,
12497            blob_cache_bytes_approx: None,
12498            pressure_reason_code: None,
12499        })
12500    }
12501
12502    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
12503        self.change_queue.drain(..).collect()
12504    }
12505
12506    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
12507        let intents = self.sync_intent_queue.drain(..).collect();
12508        if self.transport_enabled {
12509            intents
12510        } else {
12511            vec![SyncIntent::None]
12512        }
12513    }
12514
12515    fn schedule_background_retry(&mut self) -> u64 {
12516        let delay_ms = self.retry_delay_ms;
12517        self.sync_intent_queue
12518            .push_back(SyncIntent::Background { delay_ms });
12519        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
12520        self.round_retry_delay_ms = Some(delay_ms);
12521        delay_ms
12522    }
12523
12524    fn reset_background_retry(&mut self) {
12525        self.retry_delay_ms = 250;
12526    }
12527
12528    fn retryable_transport_code(code: &str) -> bool {
12529        code == "transport.failed"
12530            || code == "transport.timeout"
12531            || code == "transport.unavailable"
12532            || code == "sync.transport_failed"
12533    }
12534
12535    /// §1.5: a segment fetch that fails inside an otherwise complete round is
12536    /// invisible to the server (the round response arrived whole), so its code
12537    /// rides the following requests like a socket-round failure. A code the
12538    /// frame grammar rejects is not reportable and the previous code stays.
12539    fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
12540        if ssp2::model::valid_round_failure_code(&error.code) {
12541            self.round_failure = Some(error.code.clone());
12542        }
12543        SectionError::Abort(error.code, error.message)
12544    }
12545
12546    /// A failed round worth a background retry: a transport failure, or a
12547    /// §10.2 code the catalog marks retryable (clients may hardcode the
12548    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
12549    /// on the server's `retryable` flag.
12550    fn retryable_failure_code(code: &str) -> bool {
12551        Self::retryable_transport_code(code)
12552            || matches!(
12553                code,
12554                "sync.auth_required"
12555                    | "sync.auth_lease_required"
12556                    | "sync.auth_lease_revoked"
12557                    | "sync.internal_error"
12558                    | "sync.idempotency_cache_miss"
12559                    | "sync.schema_not_ready"
12560                    | "sync.segment_expired"
12561                    | "sync.rate_limited"
12562                    | "sync.websocket_connection_limit"
12563            )
12564    }
12565
12566    fn diagnostic_code(code: &str) -> String {
12567        let valid = !code.is_empty()
12568            && code.len() <= 96
12569            && code.contains('.')
12570            && code
12571                .bytes()
12572                .next()
12573                .is_some_and(|byte| byte.is_ascii_lowercase())
12574            && code.bytes().all(|byte| {
12575                byte.is_ascii_lowercase()
12576                    || byte.is_ascii_digit()
12577                    || matches!(byte, b'.' | b'_' | b'-')
12578            });
12579        if valid {
12580            code.to_owned()
12581        } else {
12582            "client.unknown_failure".to_owned()
12583        }
12584    }
12585
12586    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
12587        if self.sync_needed != value {
12588            if self.begin_observation("syncular_status").is_ok() {
12589                self.sync_needed = value;
12590                let batch = ChangeAccumulator {
12591                    status: true,
12592                    ..ChangeAccumulator::default()
12593                };
12594                if self.finish_observation("syncular_status", batch).is_err() {
12595                    self.rollback_observation("syncular_status");
12596                }
12597            } else {
12598                self.sync_needed = value;
12599            }
12600        }
12601        if value && interactive {
12602            self.sync_intent_queue.push_back(SyncIntent::Interactive);
12603        }
12604    }
12605
12606    fn sqlite_failure(
12607        storage_failure: &RefCell<Option<QueryReadFailure>>,
12608        error: rusqlite::Error,
12609    ) -> String {
12610        let message = error.to_string();
12611        let failure = QueryReadFailure::from(error);
12612        let code = failure.code;
12613        if code.is_some() && storage_failure.borrow().is_none() {
12614            *storage_failure.borrow_mut() = Some(failure);
12615        }
12616        match code {
12617            Some(code) => format!("{code}: {message}"),
12618            None => message,
12619        }
12620    }
12621
12622    fn begin_observation(&self, name: &str) -> Result<(), String> {
12623        if let Some(failure) = &self.coalesced_failure {
12624            return Err(failure.clone());
12625        }
12626        // SQLite rolls a whole transaction back on some I/O and capacity
12627        // errors; later frames must not commit on their own after that.
12628        if self.coalesced.is_some() && self.conn.is_autocommit() {
12629            return Err("the coalesced apply transaction was rolled back".to_owned());
12630        }
12631        self.conn
12632            .execute_batch(&format!("SAVEPOINT {name}"))
12633            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12634    }
12635
12636    /// SPEC §1.4 rule 4: open a coalesced apply transaction unless a caller
12637    /// already owns one.
12638    fn begin_coalesced_apply(&mut self) -> Result<(), String> {
12639        if self.coalesced.is_some() || !self.conn.is_autocommit() {
12640            return Ok(());
12641        }
12642        self.conn
12643            .execute_batch("BEGIN")
12644            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12645        self.coalesced = Some(CoalescedApply {
12646            rows: 0,
12647            subs: self.subs.clone(),
12648            outbox: self.outbox.clone(),
12649            failed_commits: self.failed_commits.clone(),
12650            conflicts: self.conflicts.clone(),
12651            rejections: self.rejections.clone(),
12652            schema_floor: self.schema_floor.clone(),
12653            lease_state: self.lease_state.clone(),
12654            stopped: self.stopped,
12655            upgrading: self.upgrading,
12656            sync_needed: self.sync_needed,
12657            overlay_dirty: self.overlay_dirty.snapshot(),
12658            change_queue: self.change_queue.len(),
12659            sync_intent_queue: self.sync_intent_queue.len(),
12660            last_change: self.last_change.clone(),
12661        });
12662        Ok(())
12663    }
12664
12665    /// Account the rows of the next frame or block. A unit that would take the
12666    /// coalesced transaction past the bound commits the earlier units first.
12667    fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
12668        let Some(open) = &self.coalesced else {
12669            return Ok(());
12670        };
12671        if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
12672            self.commit_coalesced_apply()?;
12673            self.begin_coalesced_apply()?;
12674        }
12675        if let Some(open) = &mut self.coalesced {
12676            open.rows += rows;
12677        }
12678        Ok(())
12679    }
12680
12681    /// COMMIT the coalesced transaction. A failed COMMIT rolls every coalesced
12682    /// frame back, restores the in-memory state of its `BEGIN`, drops the
12683    /// change batches and sync intents queued since, and fails the rest of the
12684    /// response.
12685    fn commit_coalesced_apply(&mut self) -> Result<(), String> {
12686        let Some(open) = self.coalesced.take() else {
12687            return Ok(());
12688        };
12689        let committed = if self.conn.is_autocommit() {
12690            Err("the coalesced apply transaction was rolled back".to_owned())
12691        } else {
12692            self.conn
12693                .execute_batch("COMMIT")
12694                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12695        };
12696        let Err(error) = committed else {
12697            return Ok(());
12698        };
12699        if !self.conn.is_autocommit() {
12700            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12701                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
12702            }
12703        }
12704        self.subs = open.subs;
12705        self.outbox = open.outbox;
12706        self.failed_commits = open.failed_commits;
12707        self.conflicts = open.conflicts;
12708        self.rejections = open.rejections;
12709        self.schema_floor = open.schema_floor;
12710        self.lease_state = open.lease_state;
12711        self.stopped = open.stopped;
12712        self.upgrading = open.upgrading;
12713        self.sync_needed = open.sync_needed;
12714        self.overlay_dirty.restore(open.overlay_dirty);
12715        self.change_queue.truncate(open.change_queue);
12716        self.sync_intent_queue.truncate(open.sync_intent_queue);
12717        self.last_change = open.last_change;
12718        self.coalesced_failure = Some(error.clone());
12719        Err(error)
12720    }
12721
12722    /// End the response's coalescing: COMMIT what is open and clear a failure.
12723    fn finish_coalesced_apply(&mut self) -> Result<(), String> {
12724        let committed = self.commit_coalesced_apply();
12725        match self.coalesced_failure.take() {
12726            Some(failure) => Err(failure),
12727            None => committed,
12728        }
12729    }
12730
12731    fn rollback_observation(&self, name: &str) {
12732        if let Err(error) = self
12733            .conn
12734            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
12735        {
12736            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
12737                if failure.rollback_failure.is_none() {
12738                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
12739                }
12740            }
12741        }
12742    }
12743
12744    fn finish_observation(
12745        &mut self,
12746        name: &str,
12747        mut batch: ChangeAccumulator,
12748    ) -> Result<(), String> {
12749        #[cfg(feature = "bench-internals")]
12750        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
12751        if self.failed_commits.iter().any(|(commit, _)| {
12752            commit
12753                .ops
12754                .iter()
12755                .any(|operation| batch.tables.contains_key(&operation.table))
12756        }) {
12757            batch.outcomes = true;
12758        }
12759        if !batch.touched() {
12760            self.conn
12761                .execute_batch(&format!("RELEASE {name}"))
12762                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12763            return Ok(());
12764        }
12765        let revision = self
12766            .local_revision()
12767            .checked_add(1)
12768            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
12769        self.conn
12770            .execute(
12771                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12772                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
12773            )
12774            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12775        let status = batch.status.then(|| self.status_snapshot());
12776        let event = ClientChangeBatch {
12777            revision: revision.to_string(),
12778            tables: batch
12779                .tables
12780                .into_iter()
12781                .map(|(table, scope_keys)| TableChange {
12782                    table,
12783                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
12784                })
12785                .collect(),
12786            windows: batch
12787                .windows
12788                .into_iter()
12789                .map(|((base_key, table), units)| WindowChange {
12790                    base_key,
12791                    table,
12792                    units: units.into_iter().collect(),
12793                })
12794                .collect(),
12795            status,
12796            conflicts_changed: batch.conflicts,
12797            rejections_changed: batch.rejections,
12798            outcomes_changed: batch.outcomes,
12799        };
12800        self.conn
12801            .execute_batch(&format!("RELEASE {name}"))
12802            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12803        let mut diagnostic_tables = event
12804            .tables
12805            .iter()
12806            .map(|entry| entry.table.clone())
12807            .collect::<BTreeSet<_>>()
12808            .into_iter()
12809            .collect::<Vec<_>>();
12810        let mut diagnostic_windows = event
12811            .windows
12812            .iter()
12813            .map(|entry| entry.table.clone())
12814            .collect::<BTreeSet<_>>()
12815            .into_iter()
12816            .collect::<Vec<_>>();
12817        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
12818            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
12819        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
12820        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
12821        self.last_change = Some(DiagnosticLastChange {
12822            revision: event.revision.clone(),
12823            recorded_at_ms: self.clock_now_ms(),
12824            tables: diagnostic_tables,
12825            windows: diagnostic_windows,
12826            domains_truncated,
12827            status_changed: event.status.is_some(),
12828            conflicts_changed: event.conflicts_changed,
12829            rejections_changed: event.rejections_changed,
12830            outcomes_changed: event.outcomes_changed,
12831        });
12832        self.change_queue.push_back(event);
12833        Ok(())
12834    }
12835
12836    fn record_scope_map(
12837        &self,
12838        batch: &mut ChangeAccumulator,
12839        table_name: &str,
12840        scopes: &[(String, Vec<String>)],
12841    ) {
12842        let Some(table) = self.schema.table(table_name) else {
12843            return;
12844        };
12845        for (variable, values) in scopes {
12846            let Some(scope) = table
12847                .scope_variables
12848                .iter()
12849                .find(|scope| &scope.variable == variable)
12850            else {
12851                continue;
12852            };
12853            for value in values {
12854                batch.scope(table_name, format!("{}:{value}", scope.prefix));
12855            }
12856        }
12857    }
12858
12859    /// Record a row's current scope keys from the base or visible table.
12860    fn record_row_scopes(
12861        &self,
12862        batch: &mut ChangeAccumulator,
12863        table_name: &str,
12864        row_id: &str,
12865        base: bool,
12866    ) -> bool {
12867        let Some(table) = self.schema.table(table_name) else {
12868            return false;
12869        };
12870        if table.scope_variables.is_empty() {
12871            return false;
12872        }
12873        let columns = table
12874            .scope_variables
12875            .iter()
12876            .map(|scope| quote_ident(&scope.column))
12877            .collect::<Vec<_>>()
12878            .join(", ");
12879        let full_table = if base {
12880            base_table(table_name)
12881        } else {
12882            visible_table(table_name)
12883        };
12884        let sql = format!(
12885            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
12886            row_id_predicate(table)
12887        );
12888        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
12889            return false;
12890        };
12891        let values = stmt.query_row(rusqlite::params![row_id], |row| {
12892            let mut values = Vec::with_capacity(table.scope_variables.len());
12893            for index in 0..table.scope_variables.len() {
12894                values.push(row.get::<_, Option<String>>(index)?);
12895            }
12896            Ok(values)
12897        });
12898        let Ok(values) = values else {
12899            return false;
12900        };
12901        let mut recorded = false;
12902        for (scope, value) in table.scope_variables.iter().zip(values) {
12903            if let Some(value) = value {
12904                batch.scope(table_name, format!("{}:{value}", scope.prefix));
12905                recorded = true;
12906            }
12907        }
12908        recorded
12909    }
12910
12911    fn record_commit_changes(
12912        &self,
12913        batch: &mut ChangeAccumulator,
12914        tables: &[String],
12915        changes: &[ssp2::model::Change],
12916    ) {
12917        #[cfg(feature = "bench-internals")]
12918        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
12919        for change in changes {
12920            let Some(table_name) = tables.get(change.table_index as usize) else {
12921                continue;
12922            };
12923            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
12924            if let Some(table) = self.schema.table(table_name) {
12925                for (variable, value) in &change.scopes {
12926                    if let Some(scope) = table
12927                        .scope_variables
12928                        .iter()
12929                        .find(|scope| &scope.variable == variable)
12930                    {
12931                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
12932                        precise = true;
12933                    }
12934                }
12935            }
12936            if !precise {
12937                batch.table(table_name);
12938            }
12939        }
12940    }
12941
12942    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
12943        if effective.is_empty() {
12944            return false;
12945        }
12946        let Some(table) = self.schema.table(table_name) else {
12947            return false;
12948        };
12949        let mut clauses = Vec::new();
12950        let mut params = Vec::new();
12951        for (variable, values) in effective {
12952            let Some(column) = table.scope_column(variable) else {
12953                return false;
12954            };
12955            if values.is_empty() {
12956                return false;
12957            }
12958            let placeholders = values
12959                .iter()
12960                .map(|value| {
12961                    params.push(SqlValue::Text(value.clone()));
12962                    "?"
12963                })
12964                .collect::<Vec<_>>()
12965                .join(", ");
12966            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
12967        }
12968        let sql = format!(
12969            "SELECT 1 FROM {} WHERE {} LIMIT 1",
12970            base_table(table_name),
12971            clauses.join(" AND ")
12972        );
12973        self.conn
12974            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
12975            .is_ok()
12976    }
12977
12978    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
12979    pub fn upgrading(&self) -> bool {
12980        self.upgrading
12981    }
12982
12983    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
12984    /// keeping this client's local database (identity, outbox, tables). The
12985    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
12986    /// version increases. Recreation runs the same marker guard as opening
12987    /// a durable replica, holds the guard and every write it authorizes under
12988    /// one write transaction, and preserves the current client (compiled
12989    /// schema, outbox, tables) on refusal.
12990    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
12991        let new_schema = parse_schema_json(schema_json)?;
12992        validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
12993        self.conn
12994            .execute_batch("BEGIN IMMEDIATE")
12995            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12996        let prior_schema = std::mem::replace(&mut self.schema, new_schema);
12997        // §7.4.3/§7.4.4: a reset inside this transaction mutates in-memory
12998        // state (dropped outbox commits, rejections, upgrade and floor flags,
12999        // overlay dirtiness) and appends change/intent batches. A COMMIT that
13000        // fails after the reset succeeded rolls the database back, so every
13001        // one of those fields is captured here and restored with it.
13002        let prior_subs = self.subs.clone();
13003        let prior_outbox = self.outbox.clone();
13004        let prior_rejections = self.rejections.clone();
13005        let prior_upgrading = self.upgrading;
13006        let prior_stopped = self.stopped;
13007        let prior_schema_floor = self.schema_floor.clone();
13008        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13009        let prior_sync_needed = self.sync_needed;
13010        let prior_active_round = self.active_round;
13011        let prior_changes = self.change_queue.len();
13012        let prior_intents = self.sync_intent_queue.len();
13013        let prior_last_change = self.last_change.clone();
13014        let guarded = (|| -> Result<(), String> {
13015            // §7.4.2: the marker is re-read under the write lock the caller
13016            // holds, so a replica another process upgraded meanwhile refuses
13017            // this recreation instead of having its marker and tables
13018            // downgraded.
13019            let marker = read_local_schema_version(&self.conn, self.schema.version)?;
13020            self.create_bookkeeping_tables()?;
13021            match marker {
13022                Some(version) if version < self.schema.version => self.run_schema_reset()?,
13023                None => {
13024                    self.create_synced_tables()?;
13025                    self.persist_schema_version()?;
13026                }
13027                _ => {}
13028            }
13029            // §7.4.2: recreation IS the boot for the in-memory core, so reload the
13030            // durable outbox the same-version path is about to replay. Only the
13031            // outbox is reloaded: the recovery below reads it, and the caller's
13032            // rollback snapshot already covers this field.
13033            self.outbox = self.load_outbox()?;
13034            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
13035            // this guarded boundary once the schema and marker agree. A bump went
13036            // through `run_schema_reset`, which performs the same recovery after
13037            // it creates the new physical tables and before its final replay.
13038            self.drop_unencodable_outbox(false, None)?;
13039            if marker == Some(self.schema.version)
13040                && (!self.outbox.is_empty()
13041                    || !self.failed_commits.is_empty()
13042                    || self.has_acknowledged_rows())
13043            {
13044                self.overlay_dirty.set(true);
13045                self.rebuild_overlay()?;
13046            }
13047            self.prune_unknown_subscriptions(false)?;
13048            self.save_subscription_scope_schema();
13049            // The conformance recreate is the in-memory equivalent of reopening
13050            // a durable client. Apply the same startup catch-up contract even
13051            // when the schema itself did not change.
13052            self.enqueue_startup_sync_if_needed()?;
13053            Ok(())
13054        })();
13055        let outcome = match guarded {
13056            Ok(()) => self
13057                .conn
13058                .execute_batch("COMMIT")
13059                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
13060            Err(error) => Err(error),
13061        };
13062        if let Err(error) = outcome {
13063            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13064                // Keep a failed rollback's typed storage classification.
13065                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13066            }
13067            self.schema = prior_schema;
13068            self.subs = prior_subs;
13069            self.outbox = prior_outbox;
13070            self.rejections = prior_rejections;
13071            self.upgrading = prior_upgrading;
13072            self.stopped = prior_stopped;
13073            self.schema_floor = prior_schema_floor;
13074            self.overlay_dirty.restore(prior_overlay_dirty);
13075            self.sync_needed = prior_sync_needed;
13076            self.active_round = prior_active_round;
13077            self.change_queue.truncate(prior_changes);
13078            self.sync_intent_queue.truncate(prior_intents);
13079            self.last_change = prior_last_change;
13080            // Prepared insert statements were derived from the reverted schema.
13081            self.insert_sql.borrow_mut().clear();
13082            return Err(error);
13083        }
13084        Ok(())
13085    }
13086
13087    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
13088        let startup_work = !self.stopped
13089            && (!self.outbox.is_empty()
13090                || self.has_acknowledged_rows()
13091                || !self.load_pending_evictions()?.is_empty()
13092                || self.subs.iter().any(|sub| sub.state == SubState::Active));
13093        if startup_work {
13094            self.sync_needed = true;
13095            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13096        }
13097        Ok(())
13098    }
13099
13100    /// A native client persists schema-floor stops across process restarts.
13101    /// Once the running app already satisfies that floor, the persisted stop
13102    /// is only stale evidence from an older server round (for example, the
13103    /// server was restarted after catching up to an app that was ahead).
13104    /// Clear it and let the normal startup pull re-negotiate. If the server is
13105    /// still incompatible it will return the floor again in that first round.
13106    fn clear_satisfied_persisted_schema_floor(&mut self) {
13107        let satisfied = self
13108            .schema_floor
13109            .as_ref()
13110            .and_then(|floor| floor.required_schema_version)
13111            .is_some_and(|required| self.schema.version >= required);
13112        if !satisfied {
13113            return;
13114        }
13115        self.schema_floor = None;
13116        self.stopped = false;
13117        self.delete_meta(SCHEMA_FLOOR_KEY);
13118    }
13119
13120    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
13121    /// path already swept it; this covers a same-version or fresh-install open,
13122    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
13123    /// an aware binary can apply.
13124    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
13125        let Some(replica_path) = self.previous_version_replica_path() else {
13126            return Ok(());
13127        };
13128        let max_age_ms = self
13129            .previous_version
13130            .filter(|config| config.enabled)
13131            .map(|config| config.max_age_ms);
13132        reconcile_previous_version_at_boot(
13133            &self.conn,
13134            &replica_path,
13135            self.schema.version,
13136            max_age_ms,
13137            self.clock_now_ms(),
13138        )
13139    }
13140
13141    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
13142    /// no file — an in-memory replica returns an empty path, and a `file:` URI
13143    /// has no plain sibling path — so the feature resolves as off there.
13144    fn previous_version_replica_path(&self) -> Option<String> {
13145        let path = self.conn.path()?;
13146        if path.is_empty() || path.starts_with("file:") {
13147            return None;
13148        }
13149        Some(path.to_owned())
13150    }
13151
13152    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
13153    /// and leaseState. Drops/recreates every synced table from the new
13154    /// schema, resets subscription sync-state (keeping registrations), clears
13155    /// the schema-floor stop state, rewrites the marker, drops outbox commits
13156    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
13157    fn run_schema_reset(&mut self) -> Result<(), String> {
13158        self.begin_observation("syncular_schema_reset")?;
13159        let prior_subs = self.subs.clone();
13160        let prior_outbox = self.outbox.clone();
13161        let prior_rejections = self.rejections.clone();
13162        let prior_upgrading = self.upgrading;
13163        let prior_stopped = self.stopped;
13164        let prior_schema_floor = self.schema_floor.clone();
13165        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13166        let prior_sync_needed = self.sync_needed;
13167        let prior_active_round = self.active_round;
13168        let mut batch = ChangeAccumulator::default();
13169        let result = self
13170            .run_schema_reset_observed(&mut batch, true)
13171            .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
13172        if let Err(error) = result {
13173            self.rollback_observation("syncular_schema_reset");
13174            self.subs = prior_subs;
13175            self.outbox = prior_outbox;
13176            self.rejections = prior_rejections;
13177            self.upgrading = prior_upgrading;
13178            self.stopped = prior_stopped;
13179            self.schema_floor = prior_schema_floor;
13180            self.overlay_dirty.restore(prior_overlay_dirty);
13181            self.sync_needed = prior_sync_needed;
13182            self.active_round = prior_active_round;
13183            return Err(error);
13184        }
13185        Ok(())
13186    }
13187
13188    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
13189        if self.get_meta(LOG_EPOCH_KEY).is_none() {
13190            self.begin_observation("syncular_epoch_acquisition")?;
13191            let previous_sync_needed = self.sync_needed;
13192            if let Err(error) = self.conn.execute(
13193                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13194                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13195            ) {
13196                self.rollback_observation("syncular_epoch_acquisition");
13197                return Err(Self::sqlite_failure(&self.storage_failure, error));
13198            }
13199            self.sync_needed = true;
13200            let batch = ChangeAccumulator {
13201                status: true,
13202                ..ChangeAccumulator::default()
13203            };
13204            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
13205                self.rollback_observation("syncular_epoch_acquisition");
13206                self.sync_needed = previous_sync_needed;
13207                return Err(error);
13208            }
13209            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13210            return Ok(Vec::new());
13211        }
13212        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
13213        self.begin_observation("syncular_log_epoch_reset")?;
13214        let prior_subs = self.subs.clone();
13215        let prior_outbox = self.outbox.clone();
13216        let prior_rejections = self.rejections.clone();
13217        let prior_upgrading = self.upgrading;
13218        let prior_stopped = self.stopped;
13219        let prior_schema_floor = self.schema_floor.clone();
13220        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13221        let prior_sync_needed = self.sync_needed;
13222        let prior_active_round = self.active_round;
13223        let mut batch = ChangeAccumulator::default();
13224        let result = self
13225            .run_schema_reset_observed(&mut batch, false)
13226            .and_then(|()| {
13227                self.conn
13228                    .execute(
13229                        "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13230                        rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13231                    )
13232                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13233                self.sync_needed = true;
13234                batch.status = true;
13235                self.finish_observation("syncular_log_epoch_reset", batch)
13236            });
13237        if let Err(error) = result {
13238            self.rollback_observation("syncular_log_epoch_reset");
13239            self.subs = prior_subs;
13240            self.outbox = prior_outbox;
13241            self.rejections = prior_rejections;
13242            self.upgrading = prior_upgrading;
13243            self.stopped = prior_stopped;
13244            self.schema_floor = prior_schema_floor;
13245            self.overlay_dirty.restore(prior_overlay_dirty);
13246            self.sync_needed = prior_sync_needed;
13247            self.active_round = prior_active_round;
13248            return Err(error);
13249        }
13250        self.sync_intent_queue.push_back(SyncIntent::Interactive);
13251        Ok(resets)
13252    }
13253
13254    fn run_schema_reset_observed(
13255        &mut self,
13256        batch: &mut ChangeAccumulator,
13257        capture: bool,
13258    ) -> Result<(), String> {
13259        // §7.4.2: the reset is the only path that wipes local tables for a
13260        // version bump, and it rewrites the marker last. Re-read the marker
13261        // under this reset's transaction: a replica another process upgraded
13262        // meanwhile refuses here instead of being reset to this client's
13263        // older schema.
13264        read_local_schema_version(&self.conn, self.schema.version)?;
13265        self.conn
13266            .execute_batch(
13267                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
13268            )
13269            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13270        self.cancel_sync_round();
13271        self.upgrading = true;
13272        batch.status = true;
13273        // RFC 0005 D6: classify the pending outbox against the NEW compiled
13274        // schema BEFORE the wipe, and record the advisory audit. This pass
13275        // drops nothing, so the audit sees the original pending intent; the
13276        // §7.4.4 send-time drop and the intrinsic value recovery later in this
13277        // reset are the paths that remove a commit. The classification is
13278        // shared with the send-time drop so the two cannot disagree about what
13279        // is incompatible.
13280        if self.previous_version.is_some_and(|config| config.enabled) {
13281            let previous_version = self
13282                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13283                .and_then(|value| value.parse::<i32>().ok())
13284                .unwrap_or(self.schema.version);
13285            let pending = self
13286                .outbox
13287                .iter()
13288                .map(|commit| PendingCommitAudit {
13289                    commit_id: &commit.client_commit_id,
13290                    operations: commit_audit_operations(commit),
13291                })
13292                .collect::<Vec<_>>();
13293            crate::previous_version::write_previous_version_audit(
13294                &self.conn,
13295                &crate::previous_version::build_previous_version_audit(
13296                    &self.schema,
13297                    &pending,
13298                    previous_version,
13299                    self.schema.version,
13300                    self.clock_now_ms(),
13301                ),
13302            );
13303        }
13304        // RFC 0005 D5: the container lives in its own FILE, so the reset can
13305        // never reach it. Sweep first (unconditional — also on the log-epoch
13306        // reset, which captures nothing), then capture BEFORE the wipe drops
13307        // any row. A refused capture records its reason and leaves no file.
13308        if let Some(replica_path) = self.previous_version_replica_path() {
13309            sweep_previous_version_container(&self.conn, &replica_path)?;
13310            if capture {
13311                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
13312                    let previous_version = self
13313                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13314                        .and_then(|value| value.parse::<i32>().ok())
13315                        .unwrap_or(self.schema.version);
13316                    capture_previous_version_from_replica(
13317                        &self.conn,
13318                        &replica_path,
13319                        previous_version,
13320                        self.schema.version,
13321                        &config,
13322                        self.clock_now_ms(),
13323                    )?;
13324                }
13325            }
13326        }
13327        for table in &self.schema.tables {
13328            batch.table(&table.name);
13329        }
13330        for (base_key, unit, table) in self.load_registered_window_units() {
13331            batch.window(&base_key, &table, &unit);
13332        }
13333        if capture {
13334            self.prune_unknown_subscriptions(true)?;
13335        }
13336        // The per-table insert SQL is derived from the OLD column lists.
13337        self.insert_sql.borrow_mut().clear();
13338        self.overlay_dirty.set(true);
13339        // Drop every synced table (base + visible) that currently exists —
13340        // discovered from sqlite_master so a bump that adds/removes tables is
13341        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
13342        // and the blob cache) are preserved; base tables are `_syncular_base_*`
13343        // so they are matched explicitly, not by the bookkeeping filter.
13344        // Drop virtual tables first so SQLite removes every FTS shadow table
13345        // atomically instead of the generic discovery tearing it apart.
13346        let virtual_tables: Vec<String> = {
13347            let mut stmt = self
13348                .conn
13349                .prepare(
13350                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
13351                )
13352                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13353            let rows = stmt
13354                .query_map([], |row| row.get::<_, String>(0))
13355                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13356            rows.filter_map(Result::ok)
13357                .filter(|name| is_synced_table_name(name))
13358                .collect()
13359        };
13360        for name in virtual_tables {
13361            self.conn
13362                .execute_batch(&format!(
13363                    "DROP TABLE IF EXISTS {}",
13364                    quote_ident(&format!("_syncular_fts_{name}"))
13365                ))
13366                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13367            self.conn
13368                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13369                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13370        }
13371        let existing: Vec<String> = {
13372            let mut stmt = self
13373                .conn
13374                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
13375                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13376            let rows = stmt
13377                .query_map([], |row| row.get::<_, String>(0))
13378                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13379            rows.filter_map(Result::ok)
13380                .filter(|name| is_synced_table_name(name))
13381                .collect()
13382        };
13383        for name in &existing {
13384            if let Some(table) = name.strip_prefix("_syncular_base_") {
13385                batch.table(table);
13386            } else if !name.starts_with("_syncular_") {
13387                batch.table(name);
13388            }
13389        }
13390        for name in existing {
13391            self.conn
13392                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13393                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13394        }
13395        // Recreate the synced tables from the NEW schema.
13396        self.create_synced_tables()?;
13397        // Reset every subscription's sync-state, keeping the registration.
13398        for sub in &mut self.subs {
13399            sub.cursor = -1;
13400            sub.bootstrap_state = None;
13401            sub.effective = None;
13402            sub.state = SubState::Active;
13403            sub.reason_code = None;
13404            sub.synced_once = false;
13405        }
13406        let subs = self.subs.clone();
13407        for sub in &subs {
13408            self.persist_sub(sub)?;
13409        }
13410        // The stop state is over: this client now ships a servable schema.
13411        self.stopped = false;
13412        self.schema_floor = None;
13413        self.delete_meta(SCHEMA_FLOOR_KEY);
13414        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
13415        self.persist_schema_version()?;
13416        self.save_subscription_scope_schema();
13417        // §7.1/§7.4.4: the previous-version capture and audit above saw the
13418        // original pending intent; the synced tables now carry the new schema, so
13419        // recover intrinsically codec-invalid persisted values here, before the
13420        // final replay. A pending upsert that names a column the new schema
13421        // removed stays pending for the send-time `drop_incompatible_outbox`
13422        // prepass. The reset wrapper snapshots and restores the outbox,
13423        // rejections, and change queues, so a failed commit rolls this recovery
13424        // back with the rest of the reset.
13425        // §7.4.4: the enclosing reset observation owns the publication, so the
13426        // recovery contributes its rejection, outcome, and table domains to it.
13427        self.drop_unencodable_outbox(false, Some(batch))?;
13428        // Re-apply the surviving outbox optimistically over the empty tables.
13429        self.rebuild_overlay()?;
13430        Ok(())
13431    }
13432
13433    /// §7.4.4 send-time prepass: a persisted upsert whose values reference a
13434    /// column the current schema lacks (or a removed table) cannot be encoded
13435    /// (`commit_audit_operations` inspects upserts, so a value-free delete stays
13436    /// encodable and is validated by the server). Drop the commit, undo its
13437    /// optimistic projection, and raise a client-local
13438    /// `sync.outbox_incompatible` rejection.
13439    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
13440        let incompatible = self
13441            .outbox
13442            .iter()
13443            .filter(|commit| {
13444                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
13445            })
13446            .cloned()
13447            .collect::<Vec<_>>();
13448        if incompatible.is_empty() {
13449            return Ok(false);
13450        }
13451        // §7.4.4: one observation covers the durable outcome writes, the outbox
13452        // removal, the in-memory drop, and the overlay rebuild, so a storage
13453        // fault leaves the replica and the client unchanged and publishes no
13454        // change; a successful drop publishes exactly one batch carrying the
13455        // rejections, outcomes, status, and the tables whose projection changed.
13456        let prior_outbox = self.outbox.clone();
13457        let prior_rejections = self.rejections.clone();
13458        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13459        let owns_transaction = self.conn.is_autocommit();
13460        self.begin_observation("syncular_outbox_drop")?;
13461        let mut batch = ChangeAccumulator::default();
13462        let dropped = (|| -> Result<(), String> {
13463            let mut rejections = Vec::new();
13464            for commit in &incompatible {
13465                let results = commit
13466                    .ops
13467                    .iter()
13468                    .enumerate()
13469                    .map(|(op_index, operation)| {
13470                        let rejection = RejectionRecord {
13471                            client_commit_id: commit.client_commit_id.clone(),
13472                            op_index: op_index as i32,
13473                            code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
13474                            message: "the persisted commit cannot encode under the current schema"
13475                                .to_owned(),
13476                            retryable: false,
13477                            details: None,
13478                            operation: Some(CommitOperation::from(operation)),
13479                        };
13480                        rejections.push(rejection.clone());
13481                        CommitOperationOutcome::Error { rejection }
13482                    })
13483                    .collect::<Vec<_>>();
13484                self.persist_commit_outcome(
13485                    &commit.client_commit_id,
13486                    CommitOutcomeStatus::Rejected,
13487                    &results,
13488                    Some(&commit.ops),
13489                )?;
13490                self.delete_outbox_persisted(&commit.client_commit_id)?;
13491                // A removed table has no local projection to invalidate; every
13492                // surviving table the dropped commit touched does.
13493                for operation in &commit.ops {
13494                    if self.schema.table(&operation.table).is_some() {
13495                        batch.table(&operation.table);
13496                    }
13497                }
13498            }
13499            self.prune_commit_outcomes()?;
13500            let incompatible_ids = incompatible
13501                .iter()
13502                .map(|commit| commit.client_commit_id.as_str())
13503                .collect::<BTreeSet<_>>();
13504            self.outbox
13505                .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
13506            self.rejections.extend(rejections);
13507            // The dropped commits' purely-optimistic rows are undone: the
13508            // visible projection is re-derived from the surviving base + outbox.
13509            self.overlay_dirty.set(true);
13510            self.rebuild_overlay()?;
13511            batch.status = true;
13512            batch.rejections = true;
13513            batch.outcomes = true;
13514            Ok(())
13515        })();
13516        let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
13517        if let Err(error) = result {
13518            // An outer RELEASE is the COMMIT. Roll back that transaction
13519            // directly: retrying RELEASE after ROLLBACK TO still needs the
13520            // reader's lock. A nested observation only rolls back its savepoint.
13521            if owns_transaction {
13522                if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13523                    let mut failure = self.storage_failure.borrow_mut();
13524                    let failure =
13525                        failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
13526                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
13527                }
13528            } else {
13529                self.rollback_observation("syncular_outbox_drop");
13530            }
13531            self.outbox = prior_outbox;
13532            self.rejections = prior_rejections;
13533            self.overlay_dirty.restore(prior_overlay_dirty);
13534            return Err(error);
13535        }
13536        Ok(true)
13537    }
13538
13539    /// §7.4.3: (re)create the base + visible table pair for every synced
13540    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
13541    fn create_synced_tables(&self) -> Result<(), String> {
13542        for table in &self.schema.tables {
13543            // The base half + the visible half form the synced-table pair. An
13544            // index name is global in SQLite, so the base half's indexes are
13545            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
13546            for (full, index_prefix) in [
13547                (base_table(&table.name), "_syncular_base_"),
13548                (visible_table(&table.name), ""),
13549            ] {
13550                let mut cols: Vec<String> =
13551                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13552                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
13553                let sql = format!(
13554                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
13555                    cols.join(", "),
13556                    quote_ident(&table.primary_key)
13557                );
13558                self.conn
13559                    .execute(&sql, [])
13560                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13561                // Local secondary indexes (CREATE INDEX subset). Created on
13562                // both halves so mirror reads hit an index on either. Runs on
13563                // both the initial create and the §7.4.3 reset recreate path.
13564                for index in &table.indexes {
13565                    let unique = if index.unique { "UNIQUE " } else { "" };
13566                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
13567                    let cols_sql = index
13568                        .columns
13569                        .iter()
13570                        .map(|c| quote_ident(c))
13571                        .collect::<Vec<_>>()
13572                        .join(", ");
13573                    let index_sql = format!(
13574                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
13575                    );
13576                    self.conn
13577                        .execute(&index_sql, [])
13578                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13579                }
13580            }
13581        }
13582        // FTS exists only on the visible half. It is a local search
13583        // projection, never a synced/base table or a wire-schema member.
13584        for table in &self.schema.tables {
13585            for index in &table.fts_indexes {
13586                self.create_fts_projection(table, index)?;
13587            }
13588        }
13589        Ok(())
13590    }
13591
13592    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
13593        let count: i64 = self
13594            .conn
13595            .query_row(
13596                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
13597                rusqlite::params![index.name],
13598                |row| row.get(0),
13599            )
13600            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13601        Ok(count > 0)
13602    }
13603
13604    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
13605        for suffix in ["ai", "ad", "au"] {
13606            self.conn
13607                .execute(
13608                    &format!(
13609                        "DROP TRIGGER IF EXISTS {}",
13610                        quote_ident(&format!("{}_{suffix}", index.name))
13611                    ),
13612                    [],
13613                )
13614                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13615        }
13616        Ok(())
13617    }
13618
13619    fn create_fts_triggers(
13620        &self,
13621        table: &TableSchema,
13622        index: &FtsIndexSchema,
13623    ) -> Result<(), String> {
13624        let fts = quote_ident(&index.name);
13625        let source = visible_table(&table.name);
13626        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
13627        let pk = quote_ident(&table.primary_key);
13628        let projection_columns = std::iter::once(source_id.clone())
13629            .chain(index.columns.iter().map(|column| quote_ident(column)))
13630            .collect::<Vec<_>>()
13631            .join(", ");
13632        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
13633            .chain(
13634                index
13635                    .columns
13636                    .iter()
13637                    .map(|column| format!("new.{}", quote_ident(column))),
13638            )
13639            .collect::<Vec<_>>()
13640            .join(", ");
13641        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13642        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)");
13643        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)");
13644        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})");
13645        let bi = quote_ident(&format!("{}_bi", index.name));
13646        let ai = quote_ident(&format!("{}_ai", index.name));
13647        let ad = quote_ident(&format!("{}_ad", index.name));
13648        let au = quote_ident(&format!("{}_au", index.name));
13649        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
13650        let sql = format!(
13651            "DROP TRIGGER IF EXISTS {bi};
13652             DROP TRIGGER IF EXISTS {ai};
13653             DROP TRIGGER IF EXISTS {ad};
13654             DROP TRIGGER IF EXISTS {au};
13655             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
13656             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
13657             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
13658             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
13659        );
13660        self.conn
13661            .execute_batch(&sql)
13662            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13663    }
13664
13665    fn rebuild_fts_projection(
13666        &self,
13667        table: &TableSchema,
13668        index: &FtsIndexSchema,
13669    ) -> Result<(), String> {
13670        let fts = quote_ident(&index.name);
13671        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
13672        let pk = quote_ident(&table.primary_key);
13673        let indexed_columns = index
13674            .columns
13675            .iter()
13676            .map(|column| quote_ident(column))
13677            .collect::<Vec<_>>();
13678        let projection_columns = std::iter::once(source_id.clone())
13679            .chain(indexed_columns.iter().cloned())
13680            .collect::<Vec<_>>()
13681            .join(", ");
13682        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13683        let sql = format!(
13684            "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};",
13685            columns = indexed_columns.join(", "),
13686            source = visible_table(&table.name),
13687        );
13688        self.conn
13689            .execute_batch(&sql)
13690            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13691    }
13692
13693    fn create_fts_projection(
13694        &self,
13695        table: &TableSchema,
13696        index: &FtsIndexSchema,
13697    ) -> Result<(), String> {
13698        self.begin_observation("syncular_fts_schema")?;
13699        let result = (|| {
13700            let existed = self.fts_projection_exists(index)?;
13701            let tokenizer = index.tokenize.replace('\'', "''");
13702            let columns = index
13703                .columns
13704                .iter()
13705                .map(|column| quote_ident(column))
13706                .collect::<Vec<_>>()
13707                .join(", ");
13708            let sql = format!(
13709            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
13710            fts = quote_ident(&index.name),
13711            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
13712        );
13713            self.conn.execute(&sql, []).map_err(|error| {
13714                format!(
13715                    "cannot create local FTS5 projection {:?}: {error}",
13716                    index.name
13717                )
13718            })?;
13719            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13720            let mapped: bool = self
13721                .conn
13722                .query_row(
13723                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
13724                    [format!("_syncular_fts_{}", index.name)],
13725                    |row| row.get(0),
13726                )
13727                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13728            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))?;
13729            if !mapped && existed {
13730                self.conn
13731                    .execute_batch(&format!(
13732                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
13733                        quote_ident(FTS_SOURCE_ID_COLUMN),
13734                        quote_ident(&index.name)
13735                    ))
13736                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13737            }
13738            self.create_fts_triggers(table, index)?;
13739            if !existed {
13740                self.rebuild_fts_projection(table, index)?;
13741            }
13742            Ok(())
13743        })();
13744        if let Err(error) = result {
13745            self.rollback_observation("syncular_fts_schema");
13746            return Err(error);
13747        }
13748        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
13749            self.rollback_observation("syncular_fts_schema");
13750            return Err(error.to_string());
13751        }
13752        Ok(())
13753    }
13754
13755    // -- persistence write-through --------------------------------------------
13756
13757    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
13758        let state = serde_json::json!({
13759            "requested": scope_map_to_json(&sub.requested),
13760            "params": sub.params,
13761            "cursor": sub.cursor,
13762            "bootstrapState": sub.bootstrap_state,
13763            "status": sub.state.name(),
13764            "reasonCode": sub.reason_code,
13765            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
13766            "syncedOnce": sub.synced_once,
13767        });
13768        self.conn.execute(
13769            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
13770            rusqlite::params![sub.id, sub.table, state.to_string()],
13771        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13772    }
13773
13774    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
13775        let ops: Vec<Value> = commit
13776            .ops
13777            .iter()
13778            .map(|op| {
13779                serde_json::json!({
13780                    "op": if op.upsert { "upsert" } else { "delete" },
13781                    "table": op.table,
13782                    "rowId": op.row_id,
13783                    "baseVersion": op.base_version,
13784                    "values": op.values.clone().map(Value::Object),
13785                })
13786            })
13787            .collect();
13788        self.conn
13789            .execute(
13790                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
13791                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
13792            )
13793            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13794        for operation in &commit.ops {
13795            let Some(values) = &operation.values else {
13796                continue;
13797            };
13798            let Some(table) = self.schema.table(&operation.table) else {
13799                continue;
13800            };
13801            for column in table
13802                .columns
13803                .iter()
13804                .filter(|column| column.ty == ColumnType::BlobRef)
13805            {
13806                let Some(Value::String(raw)) = values.get(&column.name) else {
13807                    continue;
13808                };
13809                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
13810                    continue;
13811                };
13812                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
13813                    continue;
13814                };
13815                self.conn
13816                    .execute(
13817                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
13818                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
13819                        rusqlite::params![commit.client_commit_id, blob_id],
13820                    )
13821                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13822            }
13823        }
13824        Ok(())
13825    }
13826
13827    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
13828        if let Some(commit) = self
13829            .outbox
13830            .iter()
13831            .find(|commit| commit.client_commit_id == client_commit_id)
13832        {
13833            for operation in &commit.ops {
13834                self.overlay_dirty.table(&operation.table);
13835            }
13836        }
13837
13838        self.conn
13839            .execute(
13840                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
13841                rusqlite::params![client_commit_id],
13842            )
13843            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13844        self.conn
13845            .execute(
13846                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
13847                rusqlite::params![client_commit_id],
13848            )
13849            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13850        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)", [])
13851            .map(|_| ())
13852            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13853    }
13854
13855    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
13856        match status {
13857            CommitOutcomeStatus::Applied => "applied",
13858            CommitOutcomeStatus::Cached => "cached",
13859            CommitOutcomeStatus::Conflict => "conflict",
13860            CommitOutcomeStatus::Rejected => "rejected",
13861        }
13862    }
13863
13864    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
13865        match resolution {
13866            CommitOutcomeResolution::Active => "active",
13867            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
13868            CommitOutcomeResolution::Superseded => "superseded",
13869            CommitOutcomeResolution::Dismissed => "dismissed",
13870        }
13871    }
13872
13873    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
13874        match value {
13875            "applied" => Ok(CommitOutcomeStatus::Applied),
13876            "cached" => Ok(CommitOutcomeStatus::Cached),
13877            "conflict" => Ok(CommitOutcomeStatus::Conflict),
13878            "rejected" => Ok(CommitOutcomeStatus::Rejected),
13879            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
13880        }
13881    }
13882
13883    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
13884        match value {
13885            "active" => Ok(CommitOutcomeResolution::Active),
13886            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
13887            "superseded" => Ok(CommitOutcomeResolution::Superseded),
13888            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
13889            _ => Err(format!(
13890                "invalid persisted commit outcome resolution {value:?}"
13891            )),
13892        }
13893    }
13894
13895    fn persist_commit_outcome(
13896        &self,
13897        client_commit_id: &str,
13898        status: CommitOutcomeStatus,
13899        results: &[CommitOperationOutcome],
13900        operations: Option<&[OutboxOp]>,
13901    ) -> Result<(), String> {
13902        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
13903        let operations_json = operations
13904            .map(|items| {
13905                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
13906            })
13907            .transpose()
13908            .map_err(|error| error.to_string())?;
13909        self.conn
13910            .execute(
13911                "INSERT INTO _syncular_commit_outcomes (
13912                   client_commit_id, status, recorded_at_ms, results_json,
13913                   operations_json, resolution
13914                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
13915                rusqlite::params![
13916                    client_commit_id,
13917                    Self::outcome_status_name(status),
13918                    self.clock_now_ms(),
13919                    results_json,
13920                    operations_json
13921                ],
13922            )
13923            .map(|_| ())
13924            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13925    }
13926
13927    /// §7.2: the journal holds a stored cell only in its canonical shape. A cell
13928    /// the byte-envelope rule refuses cannot be journaled verbatim.
13929    fn stored_cell_is_representable(cell: &Value) -> bool {
13930        cell.is_null()
13931            || cell.is_string()
13932            || cell.is_boolean()
13933            || cell.is_number()
13934            || is_canonical_bytes_envelope(cell)
13935    }
13936
13937    fn stored_row_is_representable(value: &Value) -> bool {
13938        value
13939            .as_object()
13940            .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
13941    }
13942
13943    fn stored_operation_is_representable(value: &Value) -> bool {
13944        value.get("table").is_some_and(Value::is_string)
13945            && value.get("rowId").is_some_and(Value::is_string)
13946            && value.get("baseVersion").is_none_or(|v| {
13947                v.as_i64()
13948                    .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
13949            })
13950            && match value.get("op").and_then(Value::as_str) {
13951                Some("upsert") => value
13952                    .get("values")
13953                    .is_some_and(Self::stored_row_is_representable),
13954                Some("delete") => value.get("values").is_none(),
13955                _ => false,
13956            }
13957    }
13958
13959    /// §7.2/§7.4.4: a dropped commit keeps its operation envelope in the journal
13960    /// only when the journal reader accepts the serialized operation, which is the
13961    /// rule `outcome_from_row` applies to this same row later.
13962    fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
13963        serde_json::to_value(CommitOperation::from(operation))
13964            .is_ok_and(|value| Self::stored_operation_is_representable(&value))
13965    }
13966
13967    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
13968        let StoredCommitOutcomeRow {
13969            sequence,
13970            client_commit_id,
13971            status,
13972            recorded_at_ms,
13973            results_json,
13974            operations_json,
13975            resolution,
13976            resolved_at_ms,
13977            replacement_client_commit_id,
13978        } = row;
13979        if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
13980            || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
13981            || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
13982        {
13983            return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
13984        }
13985        let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
13986        let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
13987        for result in &stored {
13988            let nested = match result.get("status").and_then(Value::as_str) {
13989                Some("conflict") => result.get("conflict"),
13990                Some("error") => result.get("rejection"),
13991                _ => None,
13992            };
13993            if nested
13994                .and_then(|record| record.get("operation"))
13995                .is_some_and(|op| !Self::stored_operation_is_representable(op))
13996                || (result.get("status").and_then(Value::as_str) == Some("conflict")
13997                    && nested
13998                        .and_then(|record| record.get("serverRow"))
13999                        .is_some_and(|row| !Self::stored_row_is_representable(row)))
14000            {
14001                return Err(invalid());
14002            }
14003        }
14004        let results: Vec<CommitOperationOutcome> =
14005            serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
14006        let has_conflict = results
14007            .iter()
14008            .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
14009        let has_error = results
14010            .iter()
14011            .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
14012        let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
14013        let redacted = results.is_empty() && operations_json.is_none();
14014        if match status {
14015            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
14016            CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
14017            CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
14018        } {
14019            return Err(invalid());
14020        }
14021        let operations: Option<Vec<CommitOperation>> = operations_json
14022            .map(|raw| {
14023                let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
14024                if !stored.iter().all(Self::stored_operation_is_representable) {
14025                    return Err(invalid());
14026                }
14027                serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
14028            })
14029            .transpose()?;
14030        for result in &results {
14031            match result {
14032                CommitOperationOutcome::Conflict { conflict }
14033                    if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
14034                {
14035                    return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
14036                }
14037                CommitOperationOutcome::Error { rejection } => {
14038                    if let Some(details) = &rejection.details {
14039                        let encoded = serde_json::to_string(details).map_err(|_| {
14040                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14041                        })?;
14042                        RejectionDetails::parse(&encoded).map_err(|_| {
14043                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14044                        })?;
14045                    }
14046                }
14047                _ => {}
14048            }
14049        }
14050        Ok(CommitOutcome {
14051            sequence,
14052            client_commit_id,
14053            status,
14054            recorded_at_ms,
14055            results,
14056            operations,
14057            retained_rows: None,
14058            resolution: Self::parse_outcome_resolution(&resolution)?,
14059            resolved_at_ms,
14060            replacement_client_commit_id,
14061        })
14062    }
14063
14064    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
14065    fn unique_conflicts(
14066        &self,
14067        table: &TableSchema,
14068        values: &Map<String, Value>,
14069        base: bool,
14070    ) -> Result<Vec<RetainedUniqueConflict>, String> {
14071        let source = if base {
14072            base_table(&table.name)
14073        } else {
14074            visible_table(&table.name)
14075        };
14076        let mut conflicts = Vec::new();
14077        for index in table.indexes.iter().filter(|index| index.unique) {
14078            let mut params = vec![json_param_to_sql(
14079                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
14080            )?];
14081            for name in &index.columns {
14082                let column = table
14083                    .columns
14084                    .iter()
14085                    .find(|column| column.name == *name)
14086                    .ok_or("sync.local_corrupt")?;
14087                let cell = json_to_column_value(column, values.get(name))?;
14088                params.push(owned_sql_value(RowParam::Cell(&cell))?);
14089            }
14090            let sql = format!(
14091                "SELECT * FROM {source} WHERE {} != ? AND {}",
14092                quote_ident(&table.primary_key),
14093                index
14094                    .columns
14095                    .iter()
14096                    .map(|column| format!("{} = ?", quote_ident(column)))
14097                    .collect::<Vec<_>>()
14098                    .join(" AND ")
14099            );
14100            let mut statement = self
14101                .conn
14102                .prepare_cached(&sql)
14103                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14104            let records = statement
14105                .query_map(rusqlite::params_from_iter(params), |record| {
14106                    let values = table
14107                        .columns
14108                        .iter()
14109                        .enumerate()
14110                        .map(|(index, column)| {
14111                            let value = sql_ref_to_json(column, record.get_ref(index)?);
14112                            json_to_column_value(column, Some(&value)).map_err(|message| {
14113                                rusqlite::Error::ToSqlConversionFailure(message.into())
14114                            })
14115                        })
14116                        .collect::<Result<Row, _>>()?;
14117                    Ok((values, record.get::<_, i64>(table.columns.len())?))
14118                })
14119                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14120            for record in records {
14121                let (row, version) =
14122                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14123                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
14124                conflicts.push(RetainedUniqueConflict {
14125                    index: index.name.clone(),
14126                    columns: index.columns.clone(),
14127                    row_id,
14128                    server_row: table
14129                        .columns
14130                        .iter()
14131                        .zip(&row)
14132                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14133                        .collect(),
14134                    server_version: version,
14135                });
14136            }
14137        }
14138        Ok(conflicts)
14139    }
14140
14141    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
14142        if let Some((commit, initial)) = self
14143            .failed_commits
14144            .iter()
14145            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
14146        {
14147            let mut rows = Vec::new();
14148            for operation in &commit.ops {
14149                let table = self
14150                    .schema
14151                    .table(&operation.table)
14152                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
14153                let key =
14154                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14155                        .map_err(|_| "sync.local_corrupt".to_owned())?;
14156                let base = self.stored_row(table, &operation.row_id, true)?;
14157                rows.push(RetainedCommitRow {
14158                    table: operation.table.clone(),
14159                    row_id: operation.row_id.clone(),
14160                    unique_conflicts: if operation.upsert {
14161                        self.unique_conflicts(
14162                            table,
14163                            initial
14164                                .get(&key)
14165                                .and_then(Value::as_object)
14166                                .or(operation.values.as_ref())
14167                                .ok_or("sync.local_corrupt")?,
14168                            true,
14169                        )?
14170                    } else {
14171                        Vec::new()
14172                    },
14173                    local_row: if operation.upsert {
14174                        initial
14175                            .get(&key)
14176                            .and_then(Value::as_object)
14177                            .cloned()
14178                            .or_else(|| operation.values.clone())
14179                    } else {
14180                        None
14181                    },
14182                    server_row: base.as_ref().map(|(values, _)| {
14183                        table
14184                            .columns
14185                            .iter()
14186                            .zip(values)
14187                            .map(|(column, value)| {
14188                                (column.name.clone(), column_value_to_json(value))
14189                            })
14190                            .collect()
14191                    }),
14192                    server_version: base.map(|(_, version)| version),
14193                });
14194            }
14195            outcome.retained_rows = Some(rows);
14196        }
14197        Ok(outcome)
14198    }
14199
14200    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
14201        let row = self
14202            .conn
14203            .query_row(
14204                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14205                        resolution, resolved_at_ms, replacement_client_commit_id
14206                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
14207                rusqlite::params![client_commit_id],
14208                |row| {
14209                    Ok(StoredCommitOutcomeRow {
14210                        sequence: row.get(0)?,
14211                        client_commit_id: row.get(1)?,
14212                        status: row.get(2)?,
14213                        recorded_at_ms: row.get(3)?,
14214                        results_json: row.get(4)?,
14215                        operations_json: row.get(5)?,
14216                        resolution: row.get(6)?,
14217                        resolved_at_ms: row.get(7)?,
14218                        replacement_client_commit_id: row.get(8)?,
14219                    })
14220                },
14221            )
14222            .optional()
14223            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14224        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
14225            .transpose()
14226    }
14227
14228    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
14229        if query.limit == Some(0) {
14230            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
14231        }
14232        let mut sql = String::from(
14233            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14234                    resolution, resolved_at_ms, replacement_client_commit_id
14235               FROM _syncular_commit_outcomes",
14236        );
14237        if query.active_only {
14238            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
14239        }
14240        sql.push_str(" ORDER BY seq DESC");
14241        if let Some(limit) = query.limit {
14242            sql.push_str(&format!(" LIMIT {limit}"));
14243        }
14244        let mut stmt = self
14245            .conn
14246            .prepare(&sql)
14247            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14248        let rows = stmt
14249            .query_map([], |row| {
14250                Ok(StoredCommitOutcomeRow {
14251                    sequence: row.get(0)?,
14252                    client_commit_id: row.get(1)?,
14253                    status: row.get(2)?,
14254                    recorded_at_ms: row.get(3)?,
14255                    results_json: row.get(4)?,
14256                    operations_json: row.get(5)?,
14257                    resolution: row.get(6)?,
14258                    resolved_at_ms: row.get(7)?,
14259                    replacement_client_commit_id: row.get(8)?,
14260                })
14261            })
14262            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14263        let mut outcomes = Vec::new();
14264        for row in rows {
14265            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
14266                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
14267            )?)?);
14268        }
14269        Ok(outcomes)
14270    }
14271
14272    fn prune_commit_outcomes(&self) -> Result<(), String> {
14273        #[cfg(test)]
14274        self.outcome_prune_count
14275            .set(self.outcome_prune_count.get() + 1);
14276        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
14277        let count = self
14278            .conn
14279            .query_row(
14280                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
14281                [],
14282                |row| row.get::<_, i64>(0),
14283            )
14284            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14285            as usize;
14286        let excess = count.saturating_sub(max_entries);
14287        if excess == 0 {
14288            return Ok(());
14289        }
14290        let mut stmt = self
14291            .conn
14292            .prepare(
14293                "SELECT seq FROM _syncular_commit_outcomes
14294                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
14295                  ORDER BY seq ASC LIMIT ?1",
14296            )
14297            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14298        let rows = stmt
14299            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
14300            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14301        let sequences = rows
14302            .collect::<Result<Vec<_>, _>>()
14303            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14304        drop(stmt);
14305        for sequence in sequences {
14306            self.conn
14307                .execute(
14308                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
14309                    rusqlite::params![sequence],
14310                )
14311                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14312        }
14313        Ok(())
14314    }
14315
14316    // -- driver surface ---------------------------------------------------------
14317
14318    pub fn subscribe(
14319        &mut self,
14320        id: String,
14321        table: String,
14322        scopes: Vec<(String, Vec<String>)>,
14323        params: Option<String>,
14324    ) -> Result<(), String> {
14325        if self.schema.table(&table).is_none() {
14326            return Err(format!("unknown table {table:?}"));
14327        }
14328        let registered_at = self
14329            .subs
14330            .iter()
14331            .position(|subscription| subscription.id == id);
14332        if let Some(index) = registered_at {
14333            let existing = &self.subs[index];
14334            let same_intent = existing.table == table
14335                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
14336                && existing.params == params;
14337            if !same_intent {
14338                return Err(
14339                    "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
14340                        .to_owned(),
14341                );
14342            }
14343            // §3.3: a revoked registration keeps its id and intent but no
14344            // progress. Subscribing again with the same intent re-arms it and
14345            // asks for a pull, which resolves its scopes from scratch. A
14346            // `failed` registration is a configuration error and stays put.
14347            if existing.state != SubState::Revoked {
14348                return Ok(());
14349            }
14350        }
14351        let sub = Subscription {
14352            id: id.clone(),
14353            table,
14354            requested: scopes,
14355            params,
14356            cursor: -1,
14357            bootstrap_state: None,
14358            state: SubState::Active,
14359            reason_code: None,
14360            effective: None,
14361            synced_once: false,
14362        };
14363        self.persist_sub(&sub)?;
14364        match registered_at {
14365            Some(index) => self.subs[index] = sub,
14366            None => self.subs.push(sub),
14367        }
14368        // Only a re-arm asks for a sync here: a fresh registration reaches the
14369        // next round through the host's schedule, and `set_window` (§4.8)
14370        // batches its own intent for the units it registers.
14371        if registered_at.is_some() {
14372            self.set_sync_needed(true, true);
14373        }
14374        Ok(())
14375    }
14376
14377    /// Register several subscriptions in one local transaction: the same
14378    /// rules as `subscribe` for each request, and one commit for all of them
14379    /// (on OPFS a commit costs milliseconds, so a cold start with hundreds of
14380    /// registrations pays it once). An error rolls back every registration
14381    /// of the call.
14382    pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
14383        let prior_subs = self.subs.clone();
14384        self.begin_observation("syncular_subscribe")?;
14385        let registered = (|| {
14386            for request in requests {
14387                self.subscribe(request.id, request.table, request.scopes, request.params)?;
14388            }
14389            self.conn
14390                .execute_batch("RELEASE syncular_subscribe")
14391                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14392        })();
14393        if let Err(error) = registered {
14394            self.rollback_observation("syncular_subscribe");
14395            self.subs = prior_subs;
14396            return Err(error);
14397        }
14398        Ok(())
14399    }
14400
14401    pub fn unsubscribe(&mut self, id: &str) {
14402        if self.subs.iter().any(|s| s.id == id) {
14403            self.cancel_sync_round();
14404        }
14405        self.subs.retain(|s| s.id != id);
14406        let _ = self.conn.execute(
14407            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14408            rusqlite::params![id],
14409        );
14410    }
14411
14412    // -- windowed subscriptions (§4.8) ------------------------------------------
14413
14414    /// §4.8: set the live window units for a base — a value-sharded family
14415    /// of subscriptions, one per unit. Added units get fresh subscriptions
14416    /// (image-lane bootstrap on the next sync); removed units are
14417    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
14418    /// Idempotent; re-entry cancels any deferred eviction.
14419    pub fn set_window(
14420        &mut self,
14421        base: &WindowBase,
14422        units: &[String],
14423    ) -> Result<CommandEffects, String> {
14424        let table = self
14425            .schema
14426            .table(&base.table)
14427            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
14428        if table.scope_column(&base.variable).is_none() {
14429            return Err(format!(
14430                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
14431                base.table, base.variable
14432            ));
14433        }
14434        let base_key = window_base_key(base);
14435        let wanted: std::collections::HashSet<&String> = units.iter().collect();
14436        let live = self.load_window_units(&base_key);
14437        self.begin_observation("syncular_window")?;
14438        let mut batch = ChangeAccumulator::default();
14439        let mut changed = false;
14440
14441        let prior_subs = self.subs.clone();
14442        let widened = (|| {
14443            // Widen: units wanted but not live → fresh subscription + registry row.
14444            for unit in units {
14445                if live.iter().any(|(u, _)| u == unit) {
14446                    continue;
14447                }
14448                let sub_id = derive_sub_id(base, unit);
14449                self.delete_pending_evict(&sub_id)?;
14450                self.insert_window_unit(&base_key, unit, &sub_id)?;
14451                self.subscribe(
14452                    sub_id,
14453                    base.table.clone(),
14454                    unit_scopes(base, unit),
14455                    base.params.clone(),
14456                )?;
14457                batch.window(&base_key, &base.table, unit);
14458                changed = true;
14459            }
14460
14461            self.finish_observation("syncular_window", batch)
14462        })();
14463        if let Err(error) = widened {
14464            self.rollback_observation("syncular_window");
14465            self.subs = prior_subs;
14466            return Err(error);
14467        }
14468        for (unit, sub_id) in live {
14469            if wanted.contains(&unit) {
14470                continue;
14471            }
14472            self.rebuild_overlay_if_dirty()?;
14473            let prior_subs = self.subs.clone();
14474            self.begin_observation("syncular_window")?;
14475            let effective = self
14476                .subs
14477                .iter()
14478                .find(|sub| sub.id == sub_id)
14479                .and_then(|sub| sub.effective.clone())
14480                .unwrap_or_else(|| unit_scopes(base, &unit));
14481            let mut batch = ChangeAccumulator::default();
14482            self.record_scope_map(&mut batch, &base.table, &effective);
14483            batch.window(&base_key, &base.table, &unit);
14484            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
14485            let remaining = match result {
14486                Ok(remaining) => remaining,
14487                Err(error) => {
14488                    self.rollback_observation("syncular_window");
14489                    self.subs = prior_subs;
14490                    return Err(error);
14491                }
14492            };
14493            if let Err(error) = self.finish_observation("syncular_window", batch) {
14494                self.rollback_observation("syncular_window");
14495                self.subs = prior_subs;
14496                return Err(error);
14497            }
14498            if remaining {
14499                self.drain_pending_evictions()?;
14500            }
14501            changed = true;
14502        }
14503        Ok(if changed {
14504            CommandEffects::interactive()
14505        } else {
14506            CommandEffects::none()
14507        })
14508    }
14509
14510    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
14511    /// the subset still bootstrap-pending. Registration alone is not
14512    /// completeness — a unit is pending until its subscription completes a
14513    /// bootstrap round (cursor advances past -1 with no resume token held).
14514    pub fn window_state(&self, base: &WindowBase) -> WindowState {
14515        let mut units = Vec::new();
14516        let mut pending = Vec::new();
14517        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
14518            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
14519                Some(sub) => {
14520                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
14521                }
14522                None => true,
14523            };
14524            if is_pending {
14525                pending.push(unit.clone());
14526            }
14527            units.push(unit);
14528        }
14529        WindowState { units, pending }
14530    }
14531
14532    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
14533        let mut stmt = match self
14534            .conn
14535            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
14536        {
14537            Ok(stmt) => stmt,
14538            Err(_) => return Vec::new(),
14539        };
14540        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
14541            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
14542        });
14543        match rows {
14544            Ok(rows) => rows.filter_map(Result::ok).collect(),
14545            Err(_) => Vec::new(),
14546        }
14547    }
14548
14549    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
14550        let mut stmt = match self.conn.prepare(
14551            "SELECT windows.base, windows.unit, subscriptions.tbl
14552               FROM _syncular_windows AS windows
14553               JOIN _syncular_subscriptions AS subscriptions
14554                 ON subscriptions.id = windows.sub_id
14555               ORDER BY windows.base, windows.unit",
14556        ) {
14557            Ok(stmt) => stmt,
14558            Err(_) => return Vec::new(),
14559        };
14560        let rows = stmt.query_map([], |row| {
14561            Ok((
14562                row.get::<_, String>(0)?,
14563                row.get::<_, String>(1)?,
14564                row.get::<_, String>(2)?,
14565            ))
14566        });
14567        match rows {
14568            Ok(rows) => rows.filter_map(Result::ok).collect(),
14569            Err(_) => Vec::new(),
14570        }
14571    }
14572
14573    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
14574        self.conn
14575            .query_row(
14576                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
14577                rusqlite::params![sub_id],
14578                |row| Ok((row.get(0)?, row.get(1)?)),
14579            )
14580            .ok()
14581    }
14582
14583    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
14584        self.conn
14585            .execute(
14586                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
14587                rusqlite::params![base_key, unit, sub_id],
14588            )
14589            .map(|_| ())
14590            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14591    }
14592
14593    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
14594        self.conn
14595            .execute(
14596                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
14597                rusqlite::params![base_key, unit],
14598            )
14599            .map(|_| ())
14600            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14601    }
14602
14603    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
14604    /// Deletes the unit's rows except those pinned by a pending outbox
14605    /// commit (E1); records a deferred eviction if any pin remains; discards
14606    /// the subscription's cursor/resume/effective-echo (E3) and version
14607    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
14608    /// nothing.
14609    fn evict_unit(
14610        &mut self,
14611        base_key: &str,
14612        base: &WindowBase,
14613        unit: &str,
14614        sub_id: &str,
14615    ) -> Result<bool, String> {
14616        let effective = self
14617            .subs
14618            .iter()
14619            .find(|s| s.id == sub_id)
14620            .and_then(|s| s.effective.clone())
14621            .unwrap_or_else(|| unit_scopes(base, unit));
14622        let pinned = self.pinned_row_ids(&base.table);
14623        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
14624        self.delete_window_unit(base_key, unit)?;
14625        self.conn
14626            .execute(
14627                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14628                [sub_id],
14629            )
14630            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14631        self.subs.retain(|sub| sub.id != sub_id);
14632        if deferred {
14633            self.save_pending_evict(sub_id, &base.table, &effective)?;
14634        } else {
14635            self.delete_pending_evict(sub_id)?;
14636        }
14637        Ok(remaining)
14638    }
14639
14640    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
14641    /// primary keys. Returns (more unpinned work, any scoped rows remain).
14642    /// A missing scope-column mapping fails without deleting rows.
14643    fn evict_scope_rows(
14644        &self,
14645        table_name: &str,
14646        effective: &[(String, Vec<String>)],
14647        pinned: &std::collections::HashSet<String>,
14648    ) -> Result<(bool, bool), String> {
14649        if effective.is_empty() {
14650            return Ok((false, false));
14651        }
14652        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
14653        let mut clauses = Vec::new();
14654        let mut params: Vec<SqlValue> = Vec::new();
14655        for (variable, values) in effective {
14656            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
14657            if values.is_empty() {
14658                return Ok((false, false));
14659            }
14660            let holes = values
14661                .iter()
14662                .map(|v| {
14663                    params.push(SqlValue::Text(v.clone()));
14664                    "?"
14665                })
14666                .collect::<Vec<_>>()
14667                .join(", ");
14668            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
14669        }
14670        let scope = clauses.join(" AND ");
14671        let scope_params = params.len();
14672        let pk = quote_ident(&table.primary_key);
14673        if !pinned.is_empty() {
14674            let holes = pinned
14675                .iter()
14676                .map(|id| {
14677                    params.push(SqlValue::Text(id.clone()));
14678                    "?"
14679                })
14680                .collect::<Vec<_>>()
14681                .join(", ");
14682            clauses.push(format!("{pk} NOT IN ({holes})"));
14683        }
14684        let predicate = clauses.join(" AND ");
14685        let base = base_table(table_name);
14686        let visible = visible_table(table_name);
14687        let mut ids: Vec<SqlValue> = Vec::new();
14688        for source in [&base, &visible] {
14689            let mut stmt = self
14690                .conn
14691                .prepare_cached(&format!(
14692                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
14693                ))
14694                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14695            ids = stmt
14696                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
14697                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14698                .collect::<Result<_, _>>()
14699                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14700            if !ids.is_empty() {
14701                break;
14702            }
14703        }
14704        if !ids.is_empty() {
14705            for id in &ids {
14706                self.conn
14707                    .execute(
14708                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
14709                        rusqlite::params![table_name, id],
14710                    )
14711                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14712            }
14713            let holes = vec!["?"; ids.len()].join(", ");
14714            for source in [&base, &visible] {
14715                self.conn
14716                    .execute(
14717                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
14718                        rusqlite::params_from_iter(&ids),
14719                    )
14720                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14721            }
14722        }
14723        let mut remaining = false;
14724        let mut deferred = false;
14725        for source in [&base, &visible] {
14726            remaining |= self
14727                .conn
14728                .query_row(
14729                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
14730                    rusqlite::params_from_iter(&params),
14731                    |row| row.get::<_, bool>(0),
14732                )
14733                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14734            deferred |= self
14735                .conn
14736                .query_row(
14737                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
14738                    rusqlite::params_from_iter(&params[..scope_params]),
14739                    |row| row.get::<_, bool>(0),
14740                )
14741                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14742        }
14743        Ok((remaining, deferred))
14744    }
14745
14746    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
14747    fn drain_pending_evictions(&mut self) -> Result<(), String> {
14748        let pending = self.load_pending_evictions()?;
14749        if pending.is_empty() {
14750            return Ok(());
14751        }
14752        self.rebuild_overlay_if_dirty()?;
14753        for (sub_id, table_name, effective) in pending {
14754            if self.schema.table(&table_name).is_none() {
14755                self.delete_pending_evict(&sub_id)?;
14756                continue;
14757            }
14758            loop {
14759                std::thread::yield_now();
14760                self.begin_observation("syncular_evict")?;
14761                let pinned = self.pinned_row_ids(&table_name);
14762                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
14763                let (remaining, deferred) = match result {
14764                    Ok(result) => result,
14765                    Err(error) => {
14766                        self.rollback_observation("syncular_evict");
14767                        return Err(error);
14768                    }
14769                };
14770                if !deferred {
14771                    if let Err(error) = self.delete_pending_evict(&sub_id) {
14772                        self.rollback_observation("syncular_evict");
14773                        return Err(error);
14774                    }
14775                }
14776                let mut batch = ChangeAccumulator::default();
14777                self.record_scope_map(&mut batch, &table_name, &effective);
14778                if let Err(error) = self.finish_observation("syncular_evict", batch) {
14779                    self.rollback_observation("syncular_evict");
14780                    return Err(error);
14781                }
14782                if !remaining {
14783                    break;
14784                }
14785            }
14786        }
14787        Ok(())
14788    }
14789
14790    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
14791    /// commit — rows that MUST NOT be evicted until the commit drains.
14792    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
14793        let mut pinned = std::collections::HashSet::new();
14794        for commit in self
14795            .outbox
14796            .iter()
14797            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
14798        {
14799            for op in &commit.ops {
14800                if op.table == table {
14801                    pinned.insert(op.row_id.clone());
14802                }
14803            }
14804        }
14805        pinned
14806    }
14807
14808    fn save_pending_evict(
14809        &self,
14810        sub_id: &str,
14811        table: &str,
14812        effective: &[(String, Vec<String>)],
14813    ) -> Result<(), String> {
14814        self.conn.execute(
14815            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
14816            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
14817        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14818    }
14819
14820    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
14821        self.conn
14822            .execute(
14823                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
14824                rusqlite::params![sub_id],
14825            )
14826            .map(|_| ())
14827            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14828    }
14829
14830    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
14831        let mut stmt = self
14832            .conn
14833            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
14834            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14835        let rows = stmt
14836            .query_map([], |row| {
14837                Ok((
14838                    row.get::<_, String>(0)?,
14839                    row.get::<_, String>(1)?,
14840                    row.get::<_, String>(2)?,
14841                ))
14842            })
14843            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14844        rows.map(|row| {
14845            let (sub_id, table, json) =
14846                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14847            let value: Value = serde_json::from_str(&json)
14848                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14849            let effective = json_to_scope_map(&value)
14850                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14851            Ok((sub_id, table, effective))
14852        })
14853        .collect()
14854    }
14855
14856    /// Select failed-intent retention; reload durable retained aggregates.
14857    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
14858        self.retain_failed_commits = enabled;
14859        for (commit, _) in &self.failed_commits {
14860            for operation in &commit.ops {
14861                self.overlay_dirty.table(&operation.table);
14862            }
14863        }
14864        self.failed_commits = self.load_failed_commits()?;
14865        for (commit, _) in &self.failed_commits {
14866            for operation in &commit.ops {
14867                self.overlay_dirty.table(&operation.table);
14868            }
14869        }
14870        Ok(())
14871    }
14872
14873    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
14874        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
14875            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14876        let rows = statement
14877            .query_map([], |row| {
14878                Ok((
14879                    row.get::<_, String>(0)?,
14880                    row.get::<_, String>(1)?,
14881                    row.get::<_, String>(2)?,
14882                ))
14883            })
14884            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14885        rows.map(|row| {
14886            let (id, operations, initial) =
14887                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14888            let operations: Vec<CommitOperation> =
14889                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
14890            let initial =
14891                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
14892            Ok((
14893                OutboxCommit {
14894                    client_commit_id: id,
14895                    ops: operations
14896                        .into_iter()
14897                        .map(|op| OutboxOp {
14898                            upsert: op.op == "upsert",
14899                            table: op.table,
14900                            row_id: op.row_id,
14901                            base_version: op.base_version,
14902                            values: op.values,
14903                        })
14904                        .collect(),
14905                },
14906                initial,
14907            ))
14908        })
14909        .collect()
14910    }
14911
14912    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
14913        let mut initial = Map::new();
14914        for operation in operations {
14915            let table = self
14916                .schema
14917                .table(&operation.table)
14918                .ok_or_else(|| "sync.unknown_table".to_owned())?;
14919            let initial_row = match self.stored_row(table, &operation.row_id, true)? {
14920                Some(row) => Some(row),
14921                None => self.visible_row(table, &operation.row_id)?,
14922            };
14923            if let Some((values, _)) = initial_row {
14924                let mut intended: Map<String, Value> = table
14925                    .columns
14926                    .iter()
14927                    .zip(values.iter())
14928                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14929                    .collect();
14930                if let Some(patch) = &operation.values {
14931                    intended.extend(patch.clone());
14932                }
14933                initial.insert(
14934                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14935                        .map_err(|_| "sync.local_corrupt".to_owned())?,
14936                    Value::Object(intended),
14937                );
14938            }
14939        }
14940        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
14941            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
14942            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
14943            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14944        self.failed_commits.push((
14945            OutboxCommit {
14946                client_commit_id: id.to_owned(),
14947                ops: operations.to_vec(),
14948            },
14949            initial,
14950        ));
14951        Ok(())
14952    }
14953
14954    fn drop_failed_commits(
14955        &mut self,
14956        matches: impl Fn(&str, &Map<String, Value>) -> bool,
14957    ) -> Result<Vec<OutboxCommit>, String> {
14958        let mut doomed = self.drop_acknowledged_rows(&matches)?;
14959        for (commit, initial) in &self.failed_commits {
14960            for operation in &commit.ops {
14961                let key =
14962                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14963                        .map_err(|_| "sync.local_corrupt".to_owned())?;
14964                if initial
14965                    .get(&key)
14966                    .and_then(Value::as_object)
14967                    .is_some_and(|values| matches(&operation.table, values))
14968                {
14969                    doomed.push(commit.clone());
14970                    break;
14971                }
14972            }
14973        }
14974        for commit in &doomed {
14975            self.conn
14976                .execute(
14977                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14978                    rusqlite::params![commit.client_commit_id],
14979                )
14980                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14981        }
14982        for commit in &doomed {
14983            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))?;
14984        }
14985        self.conflicts.retain(|record| {
14986            !doomed
14987                .iter()
14988                .any(|commit| commit.client_commit_id == record.client_commit_id)
14989        });
14990        self.rejections.retain(|record| {
14991            !doomed
14992                .iter()
14993                .any(|commit| commit.client_commit_id == record.client_commit_id)
14994        });
14995        self.failed_commits.retain(|(commit, _)| {
14996            !doomed
14997                .iter()
14998                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
14999        });
15000        for commit in &doomed {
15001            for operation in &commit.ops {
15002                self.overlay_dirty.table(&operation.table);
15003            }
15004        }
15005        Ok(doomed)
15006    }
15007
15008    /// Record one commit of local mutations. The returned commit ID is the
15009    /// §2.3 idempotency key. Failures are structured so callers read
15010    /// `code`/`message`/`details` instead of parsing a string prefix.
15011    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
15012        let prior = self.storage_failure.borrow_mut().take();
15013        let outcome = (|| -> Result<String, String> {
15014            if mutations.is_empty() {
15015                return Err(
15016                    "sync.invalid_request: a commit must contain at least one operation (§6.1)"
15017                        .to_owned(),
15018                );
15019            }
15020            let mut ops = Vec::with_capacity(mutations.len());
15021            for mutation in mutations {
15022                match mutation {
15023                    Mutation::Patch {
15024                        table,
15025                        values,
15026                        base_version,
15027                    } => {
15028                        let schema_table = self
15029                            .schema
15030                            .table(&table)
15031                            .ok_or_else(|| "sync.unknown_table".to_owned())?;
15032                        let values = normalize_values_casing(schema_table, values)?;
15033                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15034                        ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
15035                    }
15036                    Mutation::Upsert {
15037                        table,
15038                        values,
15039                        base_version,
15040                    } => {
15041                        let schema_table = self.schema.table(&table).ok_or_else(|| {
15042                            format!("sync.unknown_table: unknown table {table:?}")
15043                        })?;
15044                        // §5: value keys are accepted in snake_case AND the
15045                        // generated row types' camelCase; normalize to SQL truth
15046                        // before the pk lookup / codec see them.
15047                        let values = normalize_values_casing(schema_table, values)?;
15048                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15049                        // §6.7 `mutate` marks every column present: fill missing
15050                        // nullable columns with NULL before the payload is built.
15051                        let values = full_row_values(schema_table, values)?;
15052                        // §5.11: validate the payload encodes with the current
15053                        // codec. A key-selection or unknown-key failure is a
15054                        // durable rejection at the push seam (§10.3), never an
15055                        // author-time error. Full-row `mutate` presents every
15056                        // column, so no stored fallback is needed.
15057                        self.validate_author_encode(schema_table, &row_id, &values)?;
15058                        ops.push(OutboxOp {
15059                            upsert: true,
15060                            table,
15061                            row_id,
15062                            base_version,
15063                            values: Some(values),
15064                        });
15065                    }
15066                    Mutation::Delete {
15067                        table,
15068                        row_id,
15069                        base_version,
15070                    } => {
15071                        if self.schema.table(&table).is_none() {
15072                            return Err(format!("sync.unknown_table: unknown table {table:?}"));
15073                        }
15074                        ops.push(OutboxOp {
15075                            upsert: false,
15076                            table,
15077                            row_id,
15078                            base_version,
15079                            values: None,
15080                        });
15081                    }
15082                }
15083            }
15084            self.record_outbox_commit(ops)
15085        })();
15086        self.finish_authoring(prior, outcome)
15087    }
15088
15089    /// Scope the retained `storage_failure` diagnostic to this call so an
15090    /// earlier operation's failure cannot classify a new one, and surface the
15091    /// call's own classified failure with its details. A fresh classified
15092    /// failure replaces the retained diagnostic; otherwise the earlier one is
15093    /// kept for the next sync.
15094    fn finish_authoring(
15095        &self,
15096        prior: Option<QueryReadFailure>,
15097        outcome: Result<String, String>,
15098    ) -> Result<String, ClientError> {
15099        let fresh = self.storage_failure.borrow_mut().take();
15100        *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
15101        match outcome {
15102            Ok(id) => Ok(id),
15103            Err(message) => Err(fresh
15104                .map(ClientError::from)
15105                .unwrap_or_else(|| ClientError::from(message))),
15106        }
15107    }
15108
15109    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
15110        let commit = OutboxCommit {
15111            client_commit_id: uuid::Uuid::new_v4().to_string(),
15112            ops,
15113        };
15114        self.begin_observation("syncular_mutation")?;
15115        let mut batch = ChangeAccumulator::default();
15116        for op in &commit.ops {
15117            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
15118            if let Some(values) = &op.values {
15119                if let Some(table) = self.schema.table(&op.table) {
15120                    for scope in &table.scope_variables {
15121                        if let Some(Value::String(value)) = values.get(&scope.column) {
15122                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
15123                            precise = true;
15124                        }
15125                    }
15126                }
15127            }
15128            if !precise {
15129                batch.table(&op.table);
15130            }
15131        }
15132        if let Err(error) = self.persist_outbox_insert(&commit) {
15133            self.rollback_observation("syncular_mutation");
15134            return Err(error);
15135        }
15136        let was_dirty = self.overlay_dirty.snapshot();
15137        // A clean overlay contains the FIFO fold of every previous commit, so
15138        // the new commit applies over it. §7.1: an operation that does not
15139        // apply (a secondary unique collision) fails the whole commit; the
15140        // savepoint rollback removes the outbox entry and every visible write.
15141        if let Err(error) = self.rebuild_overlay_if_dirty() {
15142            self.rollback_observation("syncular_mutation");
15143            self.overlay_dirty.restore(was_dirty.clone());
15144            return Err(error);
15145        }
15146        if let Some(error) = commit
15147            .ops
15148            .iter()
15149            .find_map(|op| self.apply_outbox_op(op).err())
15150        {
15151            self.rollback_observation("syncular_mutation");
15152            self.overlay_dirty.restore(was_dirty.clone());
15153            return Err(error);
15154        }
15155        let id = commit.client_commit_id.clone();
15156        self.outbox.push(commit);
15157        batch.status = true;
15158        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
15159            self.rollback_observation("syncular_mutation");
15160            self.outbox.pop();
15161            self.overlay_dirty.restore(was_dirty.clone());
15162            return Err(error);
15163        }
15164        Ok(id)
15165    }
15166
15167    /// Record one §6.1 sparse upsert: the primary key plus the supplied
15168    /// non-scope columns are present; every other column is absent and stays
15169    /// untouched on the server and in the local overlay. An absent local base
15170    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
15171    /// Failures are structured so callers read `code`/`message`/`details`.
15172    pub fn patch(
15173        &mut self,
15174        table: &str,
15175        row_id: &str,
15176        partial: Map<String, Value>,
15177        base_version: Option<i64>,
15178    ) -> Result<String, ClientError> {
15179        let prior = self.storage_failure.borrow_mut().take();
15180        let outcome = self.perform_patch(table, row_id, partial, base_version);
15181        self.finish_authoring(prior, outcome)
15182    }
15183
15184    /// The unscoped patch body. Public authoring entry points scope
15185    /// `storage_failure` around the whole operation, including any pre-read.
15186    fn perform_patch(
15187        &mut self,
15188        table: &str,
15189        row_id: &str,
15190        partial: Map<String, Value>,
15191        base_version: Option<i64>,
15192    ) -> Result<String, String> {
15193        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
15194        self.record_outbox_commit(vec![operation])
15195    }
15196
15197    fn prepare_patch(
15198        &self,
15199        table: &str,
15200        row_id: &str,
15201        partial: Map<String, Value>,
15202        base_version: Option<i64>,
15203    ) -> Result<OutboxOp, String> {
15204        let schema_table = self
15205            .schema
15206            .table(table)
15207            .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
15208        let partial = normalize_values_casing(schema_table, partial)?;
15209        if let Some(pk) = partial.get(&schema_table.primary_key) {
15210            if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
15211                return Err(format!(
15212                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
15213                ));
15214            }
15215        }
15216        // Existence reads only the key. Locked encrypted values stay opaque;
15217        // decoding the complete row here would reject a valid plain patch.
15218        let exists: bool = self
15219            .conn
15220            .query_row(
15221                &format!(
15222                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
15223                    visible_table(table),
15224                    row_id_predicate(schema_table)
15225                ),
15226                rusqlite::params![row_id],
15227                |row| row.get(0),
15228            )
15229            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15230        if !exists {
15231            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
15232        }
15233        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
15234        // server accepts a present scope column whose value equals the
15235        // stored row and applies it as a no-op, so a decoded envelope
15236        // round-trips. The local row is the only value the client can prove
15237        // equality against; when the row is absent locally the client cannot
15238        // prove equality against the server's stored row and fails closed.
15239        // A primary key that is also a scope column needs no stored-row
15240        // read: the primary key in a sparse payload is by construction the
15241        // row id being patched (§6.1), so its value is proven equal already.
15242        for scope in &schema_table.scope_variables {
15243            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
15244                continue;
15245            }
15246            let Some(column) = schema_table
15247                .columns
15248                .iter()
15249                .find(|column| column.name == scope.column)
15250            else {
15251                continue;
15252            };
15253            let stored = self
15254                .conn
15255                .query_row(
15256                    &format!(
15257                        "SELECT {} FROM {} WHERE {}",
15258                        quote_ident(&scope.column),
15259                        visible_table(&schema_table.name),
15260                        row_id_predicate(schema_table)
15261                    ),
15262                    rusqlite::params![row_id],
15263                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
15264                )
15265                .optional()
15266                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15267            // Decode the supplied value through the same column-type path the
15268            // rest of the patch encoding uses, so a value that only equals
15269            // the stored one in another number representation still compares
15270            // equal.
15271            let incoming = json_to_column_value(column, partial.get(&scope.column))
15272                .ok()
15273                .map(|value| column_value_to_json(&value));
15274            // A stored blob never equals a patch value, matching the server's
15275            // scope-column comparison (§3.4): bytes are unstorable as scope
15276            // values.
15277            let equal = stored.as_ref().is_some_and(|stored| {
15278                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
15279            });
15280            if !equal {
15281                return Err(format!(
15282                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
15283                    scope.column
15284                ));
15285            }
15286        }
15287        let mut values = partial;
15288        // Presence-set semantics: a proven-equal scope column is dropped, so
15289        // the patch leaves the stored value untouched (§6.2).
15290        for scope in &schema_table.scope_variables {
15291            values.remove(&scope.column);
15292        }
15293        let pk_value = match schema_table.columns[schema_table.pk_index].ty {
15294            // §2.4: the rowId of an integer key is its canonical decimal form.
15295            ColumnType::Integer => match row_id.parse::<i64>() {
15296                Ok(v)
15297                    if v.to_string() == row_id
15298                        && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
15299                            .contains(&v) =>
15300                {
15301                    Value::from(v)
15302                }
15303                _ => {
15304                    return Err(format!(
15305                    "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
15306                ))
15307                }
15308            },
15309            ColumnType::Boolean => match row_id {
15310                "true" => Value::Bool(true),
15311                "false" => Value::Bool(false),
15312                _ => {
15313                    return Err(format!(
15314                    "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
15315                ))
15316                }
15317            },
15318            _ => Value::from(row_id.to_owned()),
15319        };
15320        values.insert(schema_table.primary_key.clone(), pk_value);
15321        // §5.11: structural validation only; an absent key-id selector is
15322        // resolved at the push seam from the stored local row, and a
15323        // key-selection or unknown-key failure becomes a durable
15324        // `client.encrypt_failed` rejection there (§10.3), never an
15325        // author-time error.
15326        self.validate_author_encode(schema_table, row_id, &values)?;
15327        Ok(OutboxOp {
15328            upsert: true,
15329            table: table.to_owned(),
15330            row_id: row_id.to_owned(),
15331            base_version,
15332            values: Some(values),
15333        })
15334    }
15335
15336    /// §5.11 key-id fallback: the stored local row's selector value as a
15337    /// positional row (only the selector slot filled). Returns None when no
15338    /// selector is configured, the row is locally absent, or the stored
15339    /// value is not a usable key id.
15340    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
15341        let selector = self.encryption.key_id_columns.get(&table.name)?;
15342        let index = table
15343            .columns
15344            .iter()
15345            .position(|column| &column.name == selector)?;
15346        let sql = format!(
15347            "SELECT {} FROM {} WHERE {}",
15348            quote_ident(selector),
15349            visible_table(&table.name),
15350            row_id_predicate(table)
15351        );
15352        let key: Option<String> = self
15353            .conn
15354            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
15355            .ok()?;
15356        let key = key.filter(|key| !key.is_empty())?;
15357        let mut fallback: Row = vec![None; table.columns.len()];
15358        fallback[index] = Some(ColumnValue::String(key));
15359        Some(fallback)
15360    }
15361
15362    /// §5.11/§10.3 author-time encode check: a structural failure (a
15363    /// non-nullable NULL, a value the codec rejects) is an author-time
15364    /// error; a key-selection or unknown-key failure is deferred to the push
15365    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
15366    fn validate_author_encode(
15367        &self,
15368        table: &TableSchema,
15369        row_id: &str,
15370        values: &Map<String, Value>,
15371    ) -> Result<(), String> {
15372        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
15373            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
15374            result => result.map(|_| ()),
15375        }
15376    }
15377
15378    /// §5.11/§10.3: drop every pending commit whose push encode fails with
15379    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
15380    /// raise one durable rejection per commit. `sync()` never aborts for an
15381    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
15382    /// undoing the dropped commit's purely-optimistic rows.
15383    /// §7.1/§7.4.4: drop pending commits whose values the current codec refuses
15384    /// (an envelope or a type an earlier version accepted). `with_encryption`
15385    /// additionally drops commits whose §5.11 encryption cannot resolve a key;
15386    /// the startup boundary leaves those to the send seam, where hosts configure
15387    /// keys after open.
15388    /// Recovery runs as one observation. An enclosing observation (the guarded
15389    /// schema reset) passes its `batch`, so the rejection, outcome, and table
15390    /// domains join that change event instead of publishing a second one.
15391    fn drop_unencodable_outbox(
15392        &mut self,
15393        with_encryption: bool,
15394        batch: Option<&mut ChangeAccumulator>,
15395    ) -> Result<bool, String> {
15396        let failures = self
15397            .outbox
15398            .iter()
15399            .filter_map(|commit| {
15400                commit
15401                    .ops
15402                    .iter()
15403                    .find_map(|op| {
15404                        let values = op.values.as_ref().filter(|_| op.upsert)?;
15405                        let table = self.schema.table(&op.table)?;
15406                        let fallback = self.stored_key_fallback(table, &op.row_id);
15407                        match encode_sparse_row_json(
15408                            table,
15409                            &op.row_id,
15410                            values,
15411                            &self.encryption,
15412                            fallback.as_ref(),
15413                        ) {
15414                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
15415                                with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
15416                            }
15417                            // §7.1/§7.4.4: a value the current codec refuses was
15418                            // persisted by a version that accepted it; it leaves
15419                            // through the incompatible-outbox path, so the FIFO
15420                            // queue never wedges.
15421                            Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
15422                                LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
15423                                OUTBOX_INCOMPATIBLE_CODE,
15424                            )),
15425                            _ => None,
15426                        }
15427                    })
15428                    .map(|(error, code)| (commit.clone(), error, code))
15429            })
15430            .collect::<Vec<_>>();
15431        if failures.is_empty() {
15432            return Ok(false);
15433        }
15434        // §7.4.4: one observation covers the durable outcome writes, the outbox
15435        // removal, the in-memory drop, and the overlay rebuild, so a storage fault
15436        // leaves the replica and the client unchanged, a successful recovery
15437        // publishes one batch with the rejection, outcome, and table domains, and
15438        // an ordinary open with nothing to recover publishes nothing.
15439        let prior_outbox = self.outbox.clone();
15440        let prior_rejections = self.rejections.clone();
15441        // §5.11 retention and a self-published observation mutate two more
15442        // in-memory fields before this transaction ends, so the rollback below
15443        // restores them with the rest.
15444        let prior_failed_commits = self.failed_commits.clone();
15445        let prior_changes = self.change_queue.len();
15446        let prior_intents = self.sync_intent_queue.len();
15447        let prior_last_change = self.last_change.clone();
15448        let prior_overlay_dirty = self.overlay_dirty.snapshot();
15449        let owns_observation = batch.is_none();
15450        let owns_transaction = self.conn.is_autocommit();
15451        let mut domains = ChangeAccumulator::default();
15452        if owns_observation {
15453            self.begin_observation("syncular_value_recovery")?;
15454        }
15455        let dropped = (|| -> Result<(), String> {
15456            for (commit, message, code) in failures {
15457                if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
15458                    self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
15459                }
15460                let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
15461                let rejection = RejectionRecord {
15462                    client_commit_id: commit.client_commit_id.clone(),
15463                    op_index: 0,
15464                    code: code.to_owned(),
15465                    message,
15466                    retryable: false,
15467                    details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
15468                        field_paths: None,
15469                        reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
15470                        required_action: None,
15471                        references: None,
15472                    }),
15473                    operation: representable
15474                        .then(|| commit.ops.first())
15475                        .flatten()
15476                        .map(CommitOperation::from),
15477                };
15478                self.persist_commit_outcome(
15479                    &commit.client_commit_id,
15480                    CommitOutcomeStatus::Rejected,
15481                    &[CommitOperationOutcome::Error {
15482                        rejection: rejection.clone(),
15483                    }],
15484                    representable.then_some(commit.ops.as_slice()),
15485                )?;
15486                self.delete_outbox_persisted(&commit.client_commit_id)?;
15487                self.outbox
15488                    .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
15489                self.rejections.push(rejection);
15490                for operation in &commit.ops {
15491                    domains.table(operation.table.as_str());
15492                }
15493            }
15494            domains.status = true;
15495            domains.rejections = true;
15496            domains.outcomes = true;
15497            self.prune_commit_outcomes()?;
15498            // The visible projection is re-derived from the surviving base + outbox.
15499            self.overlay_dirty.set(true);
15500            self.rebuild_overlay()?;
15501            Ok(())
15502        })();
15503        let result = match batch {
15504            Some(batch) => {
15505                if dropped.is_ok() {
15506                    for table in domains.tables.keys() {
15507                        batch.table(table.as_str());
15508                    }
15509                    batch.status |= domains.status;
15510                    batch.rejections |= domains.rejections;
15511                    batch.outcomes |= domains.outcomes;
15512                }
15513                dropped
15514            }
15515            None => {
15516                dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
15517            }
15518        };
15519        if let Err(error) = result {
15520            if owns_observation {
15521                if owns_transaction {
15522                    if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
15523                        let mut failure = self.storage_failure.borrow_mut();
15524                        let failure =
15525                            failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
15526                        failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
15527                    }
15528                } else {
15529                    self.rollback_observation("syncular_value_recovery");
15530                }
15531            }
15532            self.outbox = prior_outbox;
15533            self.rejections = prior_rejections;
15534            self.failed_commits = prior_failed_commits;
15535            self.change_queue.truncate(prior_changes);
15536            self.sync_intent_queue.truncate(prior_intents);
15537            self.last_change = prior_last_change;
15538            self.overlay_dirty.restore(prior_overlay_dirty);
15539            return Err(error);
15540        }
15541        Ok(true)
15542    }
15543
15544    pub fn pending_commit_ids(&self) -> Vec<String> {
15545        self.outbox
15546            .iter()
15547            .map(|c| c.client_commit_id.clone())
15548            .collect()
15549    }
15550
15551    /// §6.1: the sparse push payload bytes of every pending upsert operation,
15552    /// FIFO — exactly what the next push round would put on the wire. Used by
15553    /// conformance B.21(h) to pin cross-core byte identity.
15554    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
15555        let mut payloads = Vec::new();
15556        for commit in &self.outbox {
15557            for op in &commit.ops {
15558                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
15559                    continue;
15560                };
15561                let Some(table) = self.schema.table(&op.table) else {
15562                    continue;
15563                };
15564                let fallback = self.stored_key_fallback(table, &op.row_id);
15565                if let Ok(payload) = encode_sparse_row_json(
15566                    table,
15567                    &op.row_id,
15568                    values,
15569                    &self.encryption,
15570                    fallback.as_ref(),
15571                ) {
15572                    payloads.push(payload);
15573                }
15574            }
15575        }
15576        payloads
15577    }
15578
15579    pub fn conflicts(&self) -> &[ConflictRecord] {
15580        &self.conflicts
15581    }
15582
15583    pub fn rejections(&self) -> &[RejectionRecord] {
15584        &self.rejections
15585    }
15586
15587    pub fn resolve_commit_outcome(
15588        &mut self,
15589        input: ResolveCommitOutcomeInput,
15590    ) -> Result<CommitOutcome, String> {
15591        let current = self
15592            .commit_outcome(&input.client_commit_id)?
15593            .ok_or_else(|| {
15594                format!(
15595                    "sync.outcome_not_found: no durable outcome exists for {:?}",
15596                    input.client_commit_id
15597                )
15598            })?;
15599        if current.resolution != CommitOutcomeResolution::Active {
15600            return Ok(current);
15601        }
15602        if input.resolution == CommitOutcomeResolution::Active {
15603            return Err("sync.invalid_request: resolution must leave active state".to_owned());
15604        }
15605        match input.resolution {
15606            CommitOutcomeResolution::Superseded => {
15607                let replacement = input
15608                    .replacement_client_commit_id
15609                    .as_deref()
15610                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
15611                    .ok_or_else(|| {
15612                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
15613                            .to_owned()
15614                    })?;
15615                let _ = replacement;
15616            }
15617            _ if input.replacement_client_commit_id.is_some() => {
15618                return Err(
15619                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
15620                        .to_owned(),
15621                );
15622            }
15623            _ => {}
15624        }
15625        let allowed = match current.status {
15626            CommitOutcomeStatus::Conflict => matches!(
15627                input.resolution,
15628                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
15629            ),
15630            CommitOutcomeStatus::Rejected => {
15631                matches!(
15632                    input.resolution,
15633                    CommitOutcomeResolution::Superseded
15634                        | CommitOutcomeResolution::ResolvedKeepServer
15635                )
15636            }
15637            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
15638                input.resolution == CommitOutcomeResolution::Dismissed
15639            }
15640        };
15641        if !allowed {
15642            return Err(format!(
15643                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
15644                input.resolution, current.status
15645            ));
15646        }
15647
15648        self.begin_observation("syncular_outcome_resolution")?;
15649        let previous_failed = self.failed_commits.clone();
15650        let previous_conflicts = self.conflicts.clone();
15651        let previous_rejections = self.rejections.clone();
15652        let result = (|| {
15653            self.conn
15654                .execute(
15655                    "UPDATE _syncular_commit_outcomes
15656                        SET resolution = ?1, resolved_at_ms = ?2,
15657                            replacement_client_commit_id = ?3
15658                      WHERE client_commit_id = ?4 AND resolution = 'active'",
15659                    rusqlite::params![
15660                        Self::outcome_resolution_name(input.resolution),
15661                        self.clock_now_ms(),
15662                        input.replacement_client_commit_id,
15663                        input.client_commit_id
15664                    ],
15665                )
15666                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15667            let resolved = self
15668                .commit_outcome(&current.client_commit_id)?
15669                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
15670            self.conn
15671                .execute(
15672                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15673                    rusqlite::params![input.client_commit_id],
15674                )
15675                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15676            for (commit, _) in &self.failed_commits {
15677                if commit.client_commit_id == input.client_commit_id {
15678                    for operation in &commit.ops {
15679                        self.overlay_dirty.table(&operation.table);
15680                    }
15681                }
15682            }
15683            self.failed_commits
15684                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
15685            self.prune_commit_outcomes()?;
15686            Ok(resolved)
15687        })();
15688        let resolved = match result {
15689            Ok(outcome) => outcome,
15690            Err(error) => {
15691                self.rollback_observation("syncular_outcome_resolution");
15692                self.failed_commits = previous_failed;
15693                self.conflicts = previous_conflicts;
15694                self.rejections = previous_rejections;
15695                return Err(error);
15696            }
15697        };
15698        self.conflicts
15699            .retain(|record| record.client_commit_id != current.client_commit_id);
15700        self.rejections
15701            .retain(|record| record.client_commit_id != current.client_commit_id);
15702        let mut batch = ChangeAccumulator {
15703            conflicts: current.status == CommitOutcomeStatus::Conflict,
15704            rejections: current.status == CommitOutcomeStatus::Rejected,
15705            outcomes: true,
15706            ..ChangeAccumulator::default()
15707        };
15708        if let Some(operations) = &current.operations {
15709            for operation in operations {
15710                batch.table(&operation.table);
15711            }
15712        }
15713        if let Err(error) = self
15714            .rebuild_overlay_if_dirty()
15715            .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
15716        {
15717            self.rollback_observation("syncular_outcome_resolution");
15718            self.failed_commits = previous_failed;
15719            self.conflicts = previous_conflicts;
15720            self.rejections = previous_rejections;
15721            return Err(error);
15722        }
15723        Ok(resolved)
15724    }
15725
15726    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
15727        self.schema_floor.as_ref()
15728    }
15729
15730    /// §7.3.5: the client's opaque auth-lease state, if any.
15731    pub fn lease_state(&self) -> Option<&LeaseState> {
15732        self.lease_state.as_ref()
15733    }
15734
15735    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
15736    /// the two lease codes set it; other errors leave leaseState untouched.
15737    fn record_lease_error(&mut self, code: &str) {
15738        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
15739            return;
15740        }
15741        let mut next = self.lease_state.clone().unwrap_or_default();
15742        next.error_code = Some(code.to_owned());
15743        self.set_lease_state(Some(next));
15744    }
15745
15746    fn set_lease_state(&mut self, next: Option<LeaseState>) {
15747        if self.lease_state == next {
15748            return;
15749        }
15750        if self.begin_observation("syncular_lease").is_err() {
15751            return;
15752        }
15753        self.lease_state = next;
15754        if let Some(lease) = &self.lease_state {
15755            if let Ok(json) = serde_json::to_string(lease) {
15756                self.set_meta(LEASE_STATE_KEY, &json);
15757            }
15758        } else {
15759            self.delete_meta(LEASE_STATE_KEY);
15760        }
15761        let batch = ChangeAccumulator {
15762            status: true,
15763            ..ChangeAccumulator::default()
15764        };
15765        if self.finish_observation("syncular_lease", batch).is_err() {
15766            self.rollback_observation("syncular_lease");
15767        }
15768    }
15769
15770    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
15771        if self.schema_floor == next {
15772            return;
15773        }
15774        if self.begin_observation("syncular_schema_floor").is_err() {
15775            return;
15776        }
15777        self.schema_floor = next;
15778        self.stopped = self.schema_floor.is_some();
15779        if let Some(floor) = &self.schema_floor {
15780            if let Ok(json) = serde_json::to_string(floor) {
15781                self.set_meta(SCHEMA_FLOOR_KEY, &json);
15782            }
15783        } else {
15784            self.delete_meta(SCHEMA_FLOOR_KEY);
15785        }
15786        let batch = ChangeAccumulator {
15787            status: true,
15788            ..ChangeAccumulator::default()
15789        };
15790        if self
15791            .finish_observation("syncular_schema_floor", batch)
15792            .is_err()
15793        {
15794            self.rollback_observation("syncular_schema_floor");
15795        }
15796    }
15797
15798    fn set_upgrading(&mut self, value: bool) {
15799        if self.upgrading == value {
15800            return;
15801        }
15802        if self.begin_observation("syncular_upgrading").is_err() {
15803            return;
15804        }
15805        self.upgrading = value;
15806        let batch = ChangeAccumulator {
15807            status: true,
15808            ..ChangeAccumulator::default()
15809        };
15810        if self
15811            .finish_observation("syncular_upgrading", batch)
15812            .is_err()
15813        {
15814            self.rollback_observation("syncular_upgrading");
15815        }
15816    }
15817
15818    pub fn sync_needed(&self) -> bool {
15819        self.sync_needed
15820    }
15821
15822    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
15823        let sub = self.subs.iter().find(|s| s.id == id)?;
15824        Some(SubscriptionStateView {
15825            id: sub.id.clone(),
15826            table: sub.table.clone(),
15827            status: sub.state.name().to_owned(),
15828            cursor: sub.cursor,
15829            has_resume_token: sub.bootstrap_state.is_some(),
15830            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
15831            reason_code: sub.reason_code.clone(),
15832        })
15833    }
15834
15835    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
15836        let schema_table = self
15837            .schema
15838            .table(table)
15839            .ok_or_else(|| format!("unknown table {table:?}"))?;
15840        let sql = format!(
15841            "SELECT * FROM {} ORDER BY {} ASC",
15842            visible_table(table),
15843            quote_ident(&schema_table.primary_key)
15844        );
15845        let mut stmt = self
15846            .conn
15847            .prepare(&sql)
15848            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15849        let mut rows = stmt
15850            .query([])
15851            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15852        let mut out = Vec::new();
15853        while let Some(row) = rows
15854            .next()
15855            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15856        {
15857            let mut values = Map::new();
15858            for (i, column) in schema_table.columns.iter().enumerate() {
15859                let value = row
15860                    .get_ref(i)
15861                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15862                values.insert(column.name.clone(), sql_ref_to_json(column, value));
15863            }
15864            let version: i64 = row
15865                .get(schema_table.columns.len())
15866                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15867            let row_id = match values.get(&schema_table.primary_key) {
15868                Some(Value::String(s)) => s.clone(),
15869                Some(Value::Number(n)) => n.to_string(),
15870                Some(Value::Bool(b)) => b.to_string(),
15871                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
15872            };
15873            out.push(RowState {
15874                row_id,
15875                version,
15876                values,
15877            });
15878        }
15879        Ok(out)
15880    }
15881
15882    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
15883    //
15884    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
15885    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
15886    // the op with `yrs`, re-encodes the whole doc state, and records a
15887    // baseVersion-less sparse upsert carrying only the primary key and the
15888    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
15889    // the overlay re-materializes the edit immediately (optimistic apply,
15890    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
15891    // Byte-compatible with `@syncular/crdt-yjs`.
15892
15893    /// The current stored value of a `crdt` column for one row — the visible
15894    /// (optimistic) bytes, or `None` when the row is absent or the column is
15895    /// NULL (the empty document, §5.10.1). Errors if the column is not a
15896    /// `crdt` column (guards the app against a typo'd column name).
15897    #[cfg(feature = "crdt-yjs")]
15898    fn crdt_column_bytes(
15899        &self,
15900        table: &str,
15901        row_id: &str,
15902        column: &str,
15903    ) -> Result<Option<Vec<u8>>, String> {
15904        let schema_table = self
15905            .schema
15906            .table(table)
15907            .ok_or_else(|| format!("unknown table {table:?}"))?;
15908        let col = schema_table
15909            .columns
15910            .iter()
15911            .find(|c| c.name == column)
15912            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
15913        if col.ty != ColumnType::Crdt {
15914            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
15915        }
15916        let sql = format!(
15917            "SELECT {} FROM {} WHERE {}",
15918            quote_ident(column),
15919            visible_table(table),
15920            row_id_predicate(schema_table)
15921        );
15922        let bytes: Option<Vec<u8>> = self
15923            .conn
15924            .prepare_cached(&sql)
15925            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15926            .query_row(rusqlite::params![row_id], |row| {
15927                row.get::<_, Option<Vec<u8>>>(0)
15928            })
15929            .map_err(|e| match e {
15930                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
15931                other => Self::sqlite_failure(&self.storage_failure, other),
15932            })?;
15933        Ok(bytes)
15934    }
15935
15936    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
15937    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
15938    /// An absent row / NULL column is the empty document (empty string).
15939    #[cfg(feature = "crdt-yjs")]
15940    pub fn crdt_text(
15941        &self,
15942        table: &str,
15943        row_id: &str,
15944        column: &str,
15945        name: &str,
15946    ) -> Result<String, String> {
15947        let bytes = self
15948            .crdt_column_bytes(table, row_id, column)?
15949            .unwrap_or_default();
15950        crate::crdt::text(&bytes, name)
15951    }
15952
15953    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
15954    /// record the resulting crdt-only sparse operation. Returns the enqueued
15955    /// `clientCommitId`.
15956    #[cfg(feature = "crdt-yjs")]
15957    pub fn crdt_insert_text(
15958        &mut self,
15959        table: &str,
15960        row_id: &str,
15961        column: &str,
15962        name: &str,
15963        index: u32,
15964        value: &str,
15965    ) -> Result<String, ClientError> {
15966        let prior = self.storage_failure.borrow_mut().take();
15967        let outcome = (|| -> Result<String, String> {
15968            let current = self
15969                .crdt_column_bytes(table, row_id, column)?
15970                .unwrap_or_default();
15971            let update = crate::crdt::insert_text(&current, name, index, value)?;
15972            self.perform_crdt_push_update(table, row_id, column, &update)
15973        })();
15974        self.finish_authoring(prior, outcome)
15975    }
15976
15977    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
15978    /// record the resulting crdt-only sparse operation. Returns the enqueued
15979    /// `clientCommitId`.
15980    #[cfg(feature = "crdt-yjs")]
15981    pub fn crdt_delete_text(
15982        &mut self,
15983        table: &str,
15984        row_id: &str,
15985        column: &str,
15986        name: &str,
15987        index: u32,
15988        len: u32,
15989    ) -> Result<String, ClientError> {
15990        let prior = self.storage_failure.borrow_mut().take();
15991        let outcome = (|| -> Result<String, String> {
15992            let current = self
15993                .crdt_column_bytes(table, row_id, column)?
15994                .unwrap_or_default();
15995            let update = crate::crdt::delete_text(&current, name, index, len)?;
15996            self.perform_crdt_push_update(table, row_id, column, &update)
15997        })();
15998        self.finish_authoring(prior, outcome)
15999    }
16000
16001    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
16002    /// `crdt` column's current state and record the resulting crdt-only sparse
16003    /// operation. The app authored the update with its own `yrs` model.
16004    /// Returns the enqueued `clientCommitId`.
16005    #[cfg(feature = "crdt-yjs")]
16006    pub fn crdt_apply_update(
16007        &mut self,
16008        table: &str,
16009        row_id: &str,
16010        column: &str,
16011        update: &[u8],
16012    ) -> Result<String, ClientError> {
16013        let prior = self.storage_failure.borrow_mut().take();
16014        let outcome = (|| -> Result<String, String> {
16015            let current = self
16016                .crdt_column_bytes(table, row_id, column)?
16017                .unwrap_or_default();
16018            let next = crate::crdt::apply_update(&current, update)?;
16019            self.perform_crdt_push_update(table, row_id, column, &next)
16020        })();
16021        self.finish_authoring(prior, outcome)
16022    }
16023
16024    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
16025    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
16026    /// enqueue it. The server merges the crdt column and leaves every other
16027    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
16028    /// clobber a concurrent edit to another column. A locally absent row
16029    /// records the same partial operation; the server answers per §6.2.
16030    #[cfg(feature = "crdt-yjs")]
16031    fn perform_crdt_push_update(
16032        &mut self,
16033        table: &str,
16034        row_id: &str,
16035        column: &str,
16036        crdt_bytes: &[u8],
16037    ) -> Result<String, String> {
16038        let mut bytes_obj = Map::new();
16039        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
16040        let mut partial = Map::new();
16041        partial.insert(column.to_owned(), Value::Object(bytes_obj));
16042        self.perform_patch(table, row_id, partial, None)
16043    }
16044
16045    /// Run an arbitrary read-only SQL query against the local database and
16046    /// return each row as a `column-name → JSON value` map. This is the seam
16047    /// the React `useSyncQuery` live-query API needs (it takes app-authored
16048    /// SQL over the visible tables/views, not a fixed table read like
16049    /// [`read_rows`]).
16050    ///
16051    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
16052    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
16053    /// same envelope the command surface uses everywhere else. Output BLOB
16054    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
16055    ///
16056    /// The result column typing is dynamic (SQLite's stored affinity), because
16057    /// arbitrary SQL can alias, join, and compute — there is no schema column
16058    /// to consult per output cell, unlike [`read_rows`].
16059    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
16060        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
16061    }
16062
16063    /// Repository benchmark and conformance access, present only with `bench-internals`.
16064    #[cfg(feature = "bench-internals")]
16065    #[doc(hidden)]
16066    pub fn benchmark_connection(&mut self) -> &mut Connection {
16067        &mut self.conn
16068    }
16069
16070    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
16071    #[cfg(feature = "bench-internals")]
16072    #[doc(hidden)]
16073    pub fn benchmark_phases(
16074        &self,
16075        enabled: Option<bool>,
16076        reset: bool,
16077    ) -> Result<Option<Value>, String> {
16078        self.benchmark_phases.configure(enabled, reset)?;
16079        Ok(self.benchmark_phases.snapshot())
16080    }
16081
16082    /// One serialized, read-only authority observation, including durable coverage.
16083    pub fn authority_snapshot(&self) -> Result<Value, String> {
16084        validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
16085        if self.limits.authority_reads.is_empty() {
16086            return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
16087        }
16088        self.conn
16089            .execute_batch("SAVEPOINT authority_snapshot")
16090            .map_err(|e| e.to_string())?;
16091        let result = (|| {
16092            let revision = self
16093                .get_meta(LOCAL_REVISION_KEY)
16094                .ok_or("sync.local_corrupt: authority revision is missing")?;
16095            let parsed_revision = revision
16096                .parse::<u64>()
16097                .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
16098            if parsed_revision.to_string() != revision {
16099                return Err("sync.local_corrupt: authority revision is invalid".into());
16100            }
16101            let mut tables = Vec::new();
16102            for read in &self.limits.authority_reads {
16103                let table = self
16104                    .schema
16105                    .table(&read.table)
16106                    .ok_or("client.authority_read_forbidden: authority table is not declared")?;
16107                let scalar = |column: &str, value: &Value| -> Result<String, String> {
16108                    let ty = table
16109                        .columns
16110                        .iter()
16111                        .find(|c| c.name == column)
16112                        .expect("validated authority column")
16113                        .ty;
16114                    if ty == ColumnType::Boolean {
16115                        if let Some(value) = value.as_bool() {
16116                            return Ok(value.to_string());
16117                        }
16118                        return match value.as_i64() {
16119                            Some(0) => Ok("false".into()),
16120                            Some(1) => Ok("true".into()),
16121                            _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
16122                        };
16123                    }
16124                    match value {
16125                        Value::String(s) => Ok(s.clone()),
16126                        Value::Number(n) => Ok(n.to_string()),
16127                        Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
16128                            .get("$bigint")
16129                            .and_then(Value::as_str)
16130                            .map(str::to_owned)
16131                            .ok_or("sync.local_corrupt: authority integer is invalid".into()),
16132                        _ => {
16133                            Err("sync.local_corrupt: authority scalar is missing or invalid".into())
16134                        }
16135                    }
16136                };
16137                let matches = |row: &QueryRow| -> Result<bool, String> {
16138                    for (variable, values) in &read.scopes {
16139                        let column = table
16140                            .scope_column(variable)
16141                            .expect("validated authority scope");
16142                        let value = row
16143                            .get(column)
16144                            .ok_or("sync.local_corrupt: authority scope column is missing")?;
16145                        if value.is_null() || !values.contains(&scalar(column, value)?) {
16146                            return Ok(false);
16147                        }
16148                    }
16149                    Ok(true)
16150                };
16151                let selected = read
16152                    .columns
16153                    .iter()
16154                    .map(|c| quote_ident(c))
16155                    .collect::<Vec<_>>()
16156                    .join(",");
16157                let mut intent = BTreeSet::new();
16158                let mut relevant = BTreeSet::new();
16159                for row in query_connection(&self.conn,
16160                    "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=?",
16161                    &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
16162                    let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
16163                    intent.insert(id.clone());
16164                    if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
16165                        let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
16166                        if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
16167                    }
16168                }
16169                let mut rows = BTreeMap::new();
16170                for full in [base_table(&read.table), visible_table(&read.table)] {
16171                    for mut values in query_connection(
16172                        &self.conn,
16173                        &format!(
16174                            "SELECT {selected},_syncular_version AS authority_version FROM {full}"
16175                        ),
16176                        &[],
16177                    )
16178                    .map_err(|e| e.to_string())?
16179                    {
16180                        if !matches(&values)? {
16181                            continue;
16182                        }
16183                        let id = scalar(
16184                            &table.primary_key,
16185                            values
16186                                .get(&table.primary_key)
16187                                .expect("selected primary key"),
16188                        )?;
16189                        relevant.insert(id.clone());
16190                        if full == base_table(&read.table) {
16191                            let version = values
16192                                .remove("authority_version")
16193                                .expect("selected version");
16194                            let version = version
16195                                .as_i64()
16196                                .ok_or("sync.local_corrupt: authority server version is invalid")?;
16197                            if version >= 0 {
16198                                rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
16199                            }
16200                        }
16201                    }
16202                }
16203                intent.retain(|id| relevant.contains(id));
16204                let rows = rows.into_values().collect::<Vec<_>>();
16205                let mut persisted = Vec::new();
16206                for row in query_connection(
16207                    &self.conn,
16208                    "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
16209                    &[Value::String(read.table.clone())],
16210                )
16211                .map_err(|e| e.message)?
16212                {
16213                    let state: Value = serde_json::from_str(
16214                        row.get("state_json")
16215                            .and_then(Value::as_str)
16216                            .ok_or("sync.local_corrupt: authority subscription state is missing")?,
16217                    )
16218                    .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
16219                    let effective = state.get("effectiveScopes").and_then(Value::as_object);
16220                    let status = state
16221                        .get("status")
16222                        .and_then(Value::as_str)
16223                        .ok_or("sync.local_corrupt: authority subscription status is missing")?;
16224                    let cursor = state
16225                        .get("cursor")
16226                        .and_then(Value::as_i64)
16227                        .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
16228                    let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
16229                        && status == "active"
16230                        && cursor >= 0
16231                        && state.get("bootstrapState").is_none_or(Value::is_null)
16232                        && state.get("params").is_none_or(Value::is_null);
16233                    let exposed = effective.map(|s| {
16234                        read.scopes
16235                            .keys()
16236                            .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
16237                            .collect::<Map<_, _>>()
16238                    });
16239                    let requested = read
16240                        .scopes
16241                        .keys()
16242                        .map(|v| {
16243                            (
16244                                v.clone(),
16245                                state
16246                                    .get("requested")
16247                                    .and_then(|s| s.get(v))
16248                                    .cloned()
16249                                    .unwrap_or(json!([])),
16250                            )
16251                        })
16252                        .collect::<Map<_, _>>();
16253                    persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
16254                }
16255                fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
16256                    match selectors.split_first() {
16257                        None => !candidates.is_empty(),
16258                        Some(((variable, values), rest)) => values.iter().all(|value| {
16259                            covered(
16260                                rest,
16261                                &candidates
16262                                    .iter()
16263                                    .copied()
16264                                    .filter(|p| {
16265                                        p["effectiveScopes"][variable.as_str()]
16266                                            .as_array()
16267                                            .is_some_and(|held| {
16268                                                held.contains(&Value::String(value.clone()))
16269                                                    || held.contains(&Value::String("*".into()))
16270                                            })
16271                                    })
16272                                    .collect::<Vec<_>>(),
16273                            )
16274                        }),
16275                    }
16276                }
16277                let coverage = if covered(
16278                    &read.scopes.iter().collect::<Vec<_>>(),
16279                    &persisted
16280                        .iter()
16281                        .filter(|p| p["complete"] == true)
16282                        .collect::<Vec<_>>(),
16283                ) {
16284                    "complete"
16285                } else if persisted.iter().any(|p| {
16286                    p["status"] == "active"
16287                        && read.scopes.iter().all(|(v, values)| {
16288                            values.iter().any(|value| {
16289                                p["requestedScopes"][v].as_array().is_some_and(|held| {
16290                                    held.contains(&Value::String(value.clone()))
16291                                        || held.contains(&Value::String("*".into()))
16292                                })
16293                            })
16294                        })
16295                }) {
16296                    "pending"
16297                } else {
16298                    "missing"
16299                };
16300                tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
16301            }
16302            Ok(
16303                json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
16304            )
16305        })();
16306        match result {
16307            Ok(snapshot) => {
16308                self.conn
16309                    .execute_batch("RELEASE authority_snapshot")
16310                    .map_err(|e| e.to_string())?;
16311                Ok(snapshot)
16312            }
16313            Err(error) => {
16314                self.conn
16315                    .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
16316                    .map_err(|e| format!("{error}; rollback failed: {e}"))?;
16317                Err(error)
16318            }
16319        }
16320    }
16321
16322    /// Rows, coverage, and local revision from one SQLite read snapshot.
16323    /// §7.5 atomic snapshot read. An `owner` records a failure in
16324    /// `diagnostics_snapshot().query_failures` until its next successful read.
16325    pub fn query_snapshot(
16326        &mut self,
16327        sql: &str,
16328        params: &[QueryValue],
16329        coverage: &[WindowCoverage],
16330        owner: Option<QueryOwner<'_>>,
16331    ) -> Result<QuerySnapshot, String> {
16332        let result = snapshot_connection(&self.conn, sql, params, coverage);
16333        if let Some(owner) = owner {
16334            self.record_query_read(owner, result.as_ref().err());
16335        }
16336        result.map_err(|failure| failure.to_string())
16337    }
16338
16339    /// §7.6: record the outcome of one owned snapshot read, including reads a
16340    /// host ran on a separate connection. A success removes the owner's entry;
16341    /// an identical failure leaves it unchanged; a changed failure moves last.
16342    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
16343        let position = self
16344            .query_failures
16345            .iter()
16346            .position(|entry| entry.id == owner.id);
16347        let Some(failure) = failure else {
16348            if let Some(index) = position {
16349                self.query_failures.remove(index);
16350            }
16351            return;
16352        };
16353        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
16354            .into_iter()
16355            .map(str::to_owned)
16356            .collect();
16357        let code = failure.code.unwrap_or("client.query_failed").to_owned();
16358        if position.is_some_and(|index| {
16359            let entry = &self.query_failures[index];
16360            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
16361        }) {
16362            return;
16363        }
16364        if let Some(index) = position {
16365            self.query_failures.remove(index);
16366        }
16367        self.query_failures.push(DiagnosticQueryFailure {
16368            id: owner.id.to_owned(),
16369            tables,
16370            code,
16371            sqlite_code: failure.sqlite_code,
16372            at_ms: self.clock_now_ms(),
16373        });
16374        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
16375            self.query_failures.remove(0);
16376        }
16377    }
16378
16379    // -- request building ---------------------------------------------------------
16380
16381    fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
16382        #[cfg(feature = "bench-internals")]
16383        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
16384        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
16385        let max_commits = self.limits.max_push_commits_per_request;
16386        let max_ops = self
16387            .limits
16388            .max_push_operations_per_request
16389            .unwrap_or(PUSH_OPS_PER_REQUEST);
16390        let byte_cap = self.limits.max_push_request_bytes;
16391        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
16392        // bare URL — capability negotiation, decided per transport.
16393        let accept = self.limits.accept.unwrap_or(if url_capable {
16394            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
16395        } else {
16396            DEFAULT_ACCEPT
16397        });
16398        let header = Frame::ReqHeader {
16399            client_id: self.client_id.clone(),
16400            schema_version: self.schema.version,
16401            log_epoch: log_epoch.clone(),
16402        };
16403        // §1.5: the lead frames are REQ_HEADER plus the optional ROUND_FAILURE,
16404        // which stays ahead of every push, pull and subscription frame; the
16405        // byte-capacity probes below measure this exact sequence.
16406        let mut lead = vec![header];
16407        lead.extend(
16408            self.round_failure
16409                .iter()
16410                .map(|code| Frame::RoundFailure { code: code.clone() }),
16411        );
16412        let pull = Frame::PullHeader {
16413            limit_commits: self.limits.limit_commits.unwrap_or(0),
16414            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
16415            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
16416            accept,
16417        };
16418        let mut fresh = Vec::new();
16419        let mut sub_frames = Vec::new();
16420        for sub in &self.subs {
16421            if sub.state != SubState::Active {
16422                continue;
16423            }
16424            let mut scopes = sub.requested.clone();
16425            sort_scope_map(&mut scopes);
16426            sub_frames.push(Frame::Subscription {
16427                id: sub.id.clone(),
16428                table: sub.table.clone(),
16429                scopes,
16430                params: sub.params.clone().map(RawJson),
16431                cursor: sub.cursor,
16432                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
16433            });
16434            fresh.push((
16435                sub.id.clone(),
16436                sub.cursor < 0 && sub.bootstrap_state.is_none(),
16437            ));
16438        }
16439        // The fixed frames (order-independent size) and the header+pull probe
16440        // both encode canonically, so the byte budget is the exact wire size.
16441        let fixed_bytes = match byte_cap {
16442            Some(_) => {
16443                let mut frames = lead.clone();
16444                frames.push(pull.clone());
16445                frames.extend(sub_frames.iter().cloned());
16446                encode_message(&Message {
16447                    wire_version: WIRE_VERSION,
16448                    msg_kind: MsgKind::Request,
16449                    frames,
16450                })
16451                .len()
16452            }
16453            None => 0,
16454        };
16455        if let Some(cap) = byte_cap {
16456            if fixed_bytes > cap {
16457                return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
16458            }
16459        }
16460        let base_bytes = match byte_cap {
16461            Some(_) => {
16462                let mut frames = lead.clone();
16463                frames.push(pull.clone());
16464                encode_message(&Message {
16465                    wire_version: WIRE_VERSION,
16466                    msg_kind: MsgKind::Request,
16467                    frames,
16468                })
16469                .len()
16470            }
16471            None => 0,
16472        };
16473        let total_outbox = if log_epoch.is_some() {
16474            self.outbox.len()
16475        } else {
16476            0
16477        };
16478        let mut push_frames: Vec<Frame> = Vec::new();
16479        let mut pushed_ids = Vec::new();
16480        let mut ops_in_request = 0usize;
16481        let mut bytes_in_request = 0usize;
16482        let mut deferred_commits = 0usize;
16483        #[cfg(feature = "bench-internals")]
16484        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
16485        for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
16486            // §6.1/§7.1: the commit-count and operation caps are hard. A
16487            // first commit over the operation cap is a typed capacity error
16488            // and stays queued; a later one defers with the complete suffix.
16489            if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
16490                deferred_commits = total_outbox - index;
16491                break;
16492            }
16493            if ops_in_request + commit.ops.len() > max_ops {
16494                if push_frames.is_empty() {
16495                    return Err(Self::push_capacity_outcome(
16496                        "operations",
16497                        max_ops,
16498                        commit.ops.len(),
16499                        Some(commit.client_commit_id.as_str()),
16500                    ));
16501                }
16502                deferred_commits = total_outbox - index;
16503                break;
16504            }
16505            let operations = commit
16506                .ops
16507                .iter()
16508                .map(|op| {
16509                    let payload = op.values.as_ref().and_then(|values| {
16510                        let table = self.schema.table(&op.table)?;
16511                        // §0: outbox entries encode at send time with the
16512                        // current codec (validated at mutate()). §6.1: the
16513                        // values map's keys are the sparse presence set.
16514                        // §5.11: encrypted columns are encrypted here, with
16515                        // an absent key-id selector read from the stored row.
16516                        // `sync_inner` runs `drop_unencodable_outbox` first,
16517                        // so an encrypt failure cannot reach this encode; the
16518                        // author seam validated every other failure.
16519                        let fallback = self.stored_key_fallback(table, &op.row_id);
16520                        encode_sparse_row_json(
16521                            table,
16522                            &op.row_id,
16523                            values,
16524                            &self.encryption,
16525                            fallback.as_ref(),
16526                        )
16527                        .ok()
16528                    });
16529                    ssp2::model::Operation {
16530                        table: op.table.clone(),
16531                        row_id: op.row_id.clone(),
16532                        op: if op.upsert { Op::Upsert } else { Op::Delete },
16533                        base_version: op.base_version,
16534                        payload,
16535                    }
16536                })
16537                .collect();
16538            let frame = Frame::PushCommit {
16539                client_commit_id: commit.client_commit_id.clone(),
16540                operations,
16541            };
16542            let frame_bytes = match byte_cap {
16543                Some(_) => {
16544                    let mut frames = lead.clone();
16545                    frames.push(frame.clone());
16546                    frames.push(pull.clone());
16547                    encode_message(&Message {
16548                        wire_version: WIRE_VERSION,
16549                        msg_kind: MsgKind::Request,
16550                        frames,
16551                    })
16552                    .len()
16553                        - base_bytes
16554                }
16555                None => 0,
16556            };
16557            // The head is always encoded so the caller can report the full
16558            // projected request size; a later commit defers once it no longer
16559            // fits.
16560            if let Some(cap) = byte_cap {
16561                if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
16562                    deferred_commits = total_outbox - index;
16563                    break;
16564                }
16565            }
16566            push_frames.push(frame);
16567            pushed_ids.push(commit.client_commit_id.clone());
16568            ops_in_request += commit.ops.len();
16569            bytes_in_request += frame_bytes;
16570        }
16571        #[cfg(feature = "bench-internals")]
16572        drop(outbox_phase);
16573        if let Some(cap) = byte_cap {
16574            if fixed_bytes + bytes_in_request > cap {
16575                // The head alone does not fit this budget; the commit stays
16576                // queued and no transport round is attempted.
16577                let blocked = if push_frames.is_empty() {
16578                    None
16579                } else {
16580                    self.outbox.first().map(|c| c.client_commit_id.as_str())
16581                };
16582                return Err(Self::push_capacity_outcome(
16583                    "bytes",
16584                    cap,
16585                    fixed_bytes + bytes_in_request,
16586                    blocked,
16587                ));
16588            }
16589        }
16590        let mut frames = lead;
16591        frames.extend(push_frames);
16592        frames.push(pull);
16593        frames.extend(sub_frames);
16594        let message = Message {
16595            wire_version: WIRE_VERSION,
16596            msg_kind: MsgKind::Request,
16597            frames,
16598        };
16599        Ok((
16600            message,
16601            RequestMeta {
16602                pushed_ids,
16603                fresh,
16604                accept,
16605                deferred_commits,
16606            },
16607        ))
16608    }
16609
16610    /// §7.1: a configured push capacity limit cannot carry this request.
16611    fn push_capacity_outcome(
16612        kind: &str,
16613        limit: usize,
16614        size: usize,
16615        client_commit_id: Option<&str>,
16616    ) -> Box<SyncOutcome> {
16617        let mut details = Map::new();
16618        details.insert("kind".to_owned(), Value::from(kind));
16619        details.insert("limit".to_owned(), Value::from(limit as u64));
16620        details.insert("size".to_owned(), Value::from(size as u64));
16621        if let Some(id) = client_commit_id {
16622            details.insert("clientCommitId".to_owned(), Value::from(id));
16623        }
16624        Box::new(SyncOutcome::Failed {
16625            error_code: "client.push_request_too_large".into(),
16626            message: "the push request exceeds a configured capacity limit".into(),
16627            details: Some(Value::Object(details)),
16628            recommended_action: None,
16629        })
16630    }
16631
16632    // -- sync -------------------------------------------------------------------
16633
16634    /// Cloneable observation handle; no client lock is needed to read it.
16635    pub fn progress(&self) -> ProgressObserver {
16636        self.progress.clone()
16637    }
16638
16639    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
16640        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
16641            Ok(prepared) => prepared,
16642            Err(outcome) => return *outcome,
16643        };
16644        // The round's network deadline rides `PreparedSyncRound` and is scoped
16645        // to each exchange, so uploads, continuations, the main request, and
16646        // every segment fetch share one monotonic anchor without leaving
16647        // deadline state on a reused transport.
16648        let mut next = prepared;
16649        loop {
16650            match self.apply_sync_round(next.exchange(transport)) {
16651                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
16652                crate::AppliedSyncRound::Complete {
16653                    outcome, controls, ..
16654                } => {
16655                    if self.transport_enabled {
16656                        for text in controls {
16657                            let _ = transport.realtime_send(&text);
16658                        }
16659                    } else {
16660                        self.disconnect_realtime(transport);
16661                    }
16662                    return outcome;
16663                }
16664            }
16665        }
16666    }
16667
16668    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
16669        if let SyncOutcome::Failed {
16670            error_code,
16671            message,
16672            details,
16673            ..
16674        } = &mut outcome
16675        {
16676            if let Some(failure) = self.storage_failure.borrow().as_ref() {
16677                let code = failure
16678                    .code
16679                    .expect("retained storage failures are classified");
16680                *error_code = code.to_owned();
16681                *message = match code {
16682                    "client.storage_busy" => "local SQLite storage is busy",
16683                    "client.storage_full" => "local SQLite storage is full",
16684                    "client.storage_io" => "local SQLite storage I/O failed",
16685                    "client.storage_corrupt" => "local SQLite storage is corrupt",
16686                    _ => unreachable!("unrecognized classified storage failure"),
16687                }
16688                .to_owned();
16689                *details = failure.details();
16690            }
16691        }
16692        let retry_delay_ms = self.round_retry_delay_ms;
16693        self.progress.update(|p| match &outcome {
16694            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
16695                if report.failed.is_empty() =>
16696            {
16697                p.state = ProgressState::Complete
16698            }
16699            SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
16700                p.state = ProgressState::Failed;
16701                p.error_code = Some("sync.scope_revoked".into());
16702            }
16703            SyncOutcome::Failed { error_code, .. } => {
16704                p.state = ProgressState::Failed;
16705                p.error_code = Some(Self::diagnostic_code(error_code));
16706                p.retry_delay_ms = retry_delay_ms;
16707            }
16708            SyncOutcome::RealtimeUnavailable { .. } => {
16709                p.state = ProgressState::Failed;
16710                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
16711                p.retry_delay_ms = retry_delay_ms;
16712            }
16713        });
16714        let completed_at_ms = self.clock_now_ms();
16715        self.last_round = Some(match &outcome {
16716            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
16717                status: "succeeded".to_owned(),
16718                started_at_ms,
16719                completed_at_ms,
16720                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16721                counters: Some(DiagnosticRoundCounters {
16722                    pushed: report.pushed,
16723                    applied: report.applied.len(),
16724                    rejected: report.rejected.len(),
16725                    retryable: report.retryable.len(),
16726                    conflicts: report.conflicts,
16727                    commits_applied: report.commits_applied,
16728                    segment_rows_applied: report.segment_rows_applied,
16729                    bootstrapping: report.bootstrapping.len(),
16730                    resets: report.resets.len(),
16731                    revoked: report.revoked.len(),
16732                    failed: report.failed.len(),
16733                    deferred_commits: report.deferred_commits,
16734                }),
16735                error_code: None,
16736            },
16737            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
16738                status: "failed".to_owned(),
16739                started_at_ms,
16740                completed_at_ms,
16741                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16742                counters: None,
16743                error_code: Some(Self::diagnostic_code(error_code)),
16744            },
16745            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
16746                status: "failed".to_owned(),
16747                started_at_ms,
16748                completed_at_ms,
16749                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16750                counters: None,
16751                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
16752            },
16753        });
16754        outcome
16755    }
16756
16757    /// Capture one request on the client owner. Mutations made after this
16758    /// call remain in the outbox for a later round; no network I/O occurs here.
16759    pub fn prepare_sync_round(
16760        &mut self,
16761        url_capable: bool,
16762    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
16763        if !self.transport_enabled {
16764            return Err(Box::new(SyncOutcome::Failed {
16765                error_code: "sync.offline".into(),
16766                message: "transport gate is closed".into(),
16767                details: None,
16768                recommended_action: None,
16769            }));
16770        }
16771        if self.active_round.is_some() {
16772            return Err(Box::new(SyncOutcome::Failed {
16773                error_code: "client.round_in_progress".into(),
16774                message: "a sync round is already in flight".into(),
16775                details: None,
16776                recommended_action: None,
16777            }));
16778        }
16779        self.storage_failure.borrow_mut().take();
16780        self.progress.start();
16781        self.round_retry_delay_ms = None;
16782        let started_at_ms = self.clock_now_ms();
16783        let prepared = (|| {
16784            if self.stopped {
16785                return Err(Box::new(SyncOutcome::Ok(SyncReport {
16786                    schema_floor: self.schema_floor.clone(),
16787                    ..SyncReport::default()
16788                })));
16789            }
16790            if self.security_preflight {
16791                return Err(Box::new(SyncOutcome::Failed {
16792                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
16793                    message: "security preflight must complete before syncing".into(),
16794                    details: None,
16795                    recommended_action: None,
16796                }));
16797            }
16798            self.set_sync_needed(false, false);
16799            self.drain_pending_evictions()
16800                .and_then(|_| self.drop_incompatible_outbox())
16801                .and_then(|_| self.drop_unencodable_outbox(true, None))
16802                .map_err(|message| {
16803                    Box::new(SyncOutcome::Failed {
16804                        error_code: "storage.failed".into(),
16805                        message,
16806                        details: None,
16807                        recommended_action: None,
16808                    })
16809                })?;
16810            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
16811                self.pending_blob_uploads().map_err(|e| {
16812                    Box::new(SyncOutcome::Failed {
16813                        error_code: e.code,
16814                        message: e.message,
16815                        details: e.details,
16816                        recommended_action: None,
16817                    })
16818                })?
16819            } else {
16820                Vec::new()
16821            };
16822            if self.realtime_state != RealtimeState::Connected
16823                && self.realtime_policy == RealtimePolicy::Required
16824            {
16825                let retry_delay_ms = self.schedule_background_retry();
16826                self.realtime_retry_delay_ms = Some(retry_delay_ms);
16827                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
16828                    state: self.realtime_state,
16829                    reason_code: self.realtime_reason_code.clone(),
16830                    retry_delay_ms,
16831                }));
16832            }
16833            let (message, meta) = self.build_request(url_capable)?;
16834            let id = uuid::Uuid::new_v4();
16835            self.active_round = Some(id);
16836            Ok(crate::PreparedSyncRound {
16837                id,
16838                started_at_ms,
16839                message,
16840                meta,
16841                uploads: uploads.into(),
16842                realtime: self.realtime_state == RealtimeState::Connected,
16843                scopes: self
16844                    .subs
16845                    .iter()
16846                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
16847                    .collect(),
16848                #[cfg(feature = "bench-internals")]
16849                benchmark_phases: self.benchmark_phases.clone(),
16850                progress: self.progress.clone(),
16851                fixed_now: self.now_ms,
16852                round_deadline_at: None,
16853            })
16854        })();
16855        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
16856    }
16857
16858    /// Discard the reply of an old authorization/subscription context. Its
16859    /// submitted commits stay durable and can be retried with the same ids.
16860    pub fn cancel_sync_round(&mut self) {
16861        self.active_round = None;
16862    }
16863
16864    /// Apply captured network results on the owner, preserving all normal
16865    /// version, schema, content-address and cursor checks. No network I/O.
16866    pub fn apply_sync_round(
16867        &mut self,
16868        completed: crate::CompletedSyncRound,
16869    ) -> crate::AppliedSyncRound {
16870        let prepared = completed.prepared;
16871        if self.active_round != Some(prepared.id) || self.security_preflight {
16872            return crate::AppliedSyncRound::Complete {
16873                outcome: SyncOutcome::Failed {
16874                    error_code: "client.round_cancelled".into(),
16875                    message: "sync round belongs to a released client context".into(),
16876                    details: None,
16877                    recommended_action: None,
16878                },
16879                controls: Vec::new(),
16880                more: false,
16881                bootstrap_advanced: false,
16882            };
16883        }
16884        self.storage_failure.borrow_mut().take();
16885        let (response, mut downloads, transport_failed) = match completed.exchange {
16886            crate::round::ExchangeResult::Upload { id, result } => {
16887                let result = result.and_then(|()| {
16888                    self.conn
16889                        .execute(
16890                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
16891                            rusqlite::params![id],
16892                        )
16893                        .map(|_| ())
16894                        .map_err(|error| {
16895                            TransportError::new(
16896                                "client.failed",
16897                                Self::sqlite_failure(&self.storage_failure, error),
16898                            )
16899                        })
16900                });
16901                match result {
16902                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
16903                    Err(TransportError {
16904                        code,
16905                        message,
16906                        details,
16907                    }) => {
16908                        self.active_round = None;
16909                        if Self::retryable_failure_code(&code) {
16910                            self.schedule_background_retry();
16911                        }
16912                        let recommended_action = recommended_action_of(&details);
16913                        let outcome = self.finish_sync_round(
16914                            prepared.started_at_ms,
16915                            SyncOutcome::Failed {
16916                                error_code: code,
16917                                message,
16918                                details,
16919                                recommended_action,
16920                            },
16921                        );
16922                        return crate::AppliedSyncRound::Complete {
16923                            outcome,
16924                            controls: Vec::new(),
16925                            more: false,
16926                            bootstrap_advanced: false,
16927                        };
16928                    }
16929                }
16930            }
16931            crate::round::ExchangeResult::Reply {
16932                response,
16933                downloads,
16934                transport_failed,
16935            } => (response, downloads, transport_failed),
16936        };
16937        self.active_round = None;
16938        let before = self
16939            .subs
16940            .iter()
16941            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16942            .collect::<Vec<_>>();
16943        let outcome = (|| {
16944            let response = match response {
16945                Ok(response) => {
16946                    // §1.5: a complete, decodable response ends the report, an
16947                    // in-band ERROR included (the server already recorded it).
16948                    self.round_failure = None;
16949                    response
16950                }
16951                Err(TransportError {
16952                    code,
16953                    message,
16954                    details,
16955                }) => {
16956                    // §1.5: the server cannot see a round whose transport
16957                    // failed or whose response never decoded, so its code rides
16958                    // the following requests. A code the frame grammar rejects
16959                    // is not reportable, and the previous code stays.
16960                    if ssp2::model::valid_round_failure_code(&code) {
16961                        self.round_failure = Some(code.clone());
16962                    }
16963                    if transport_failed {
16964                        if prepared.realtime {
16965                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
16966                        }
16967                        self.record_lease_error(&code);
16968                    }
16969                    if Self::retryable_failure_code(&code) {
16970                        let delay = self.schedule_background_retry();
16971                        if prepared.realtime && transport_failed {
16972                            self.realtime_retry_delay_ms = Some(delay);
16973                        }
16974                    }
16975                    let recommended_action = recommended_action_of(&details);
16976                    return SyncOutcome::Failed {
16977                        error_code: code,
16978                        message,
16979                        details,
16980                        recommended_action,
16981                    };
16982                }
16983            };
16984            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
16985            if let SyncOutcome::Ok(report) = &mut outcome {
16986                report.deferred_commits = prepared.meta.deferred_commits;
16987            }
16988            match &outcome {
16989                SyncOutcome::Ok(_) => self.reset_background_retry(),
16990                SyncOutcome::Failed { error_code, .. }
16991                    if Self::retryable_failure_code(error_code) =>
16992                {
16993                    self.schedule_background_retry();
16994                }
16995                _ => {}
16996            }
16997            if prepared.meta.deferred_commits > 0 {
16998                self.set_sync_needed(true, true);
16999            }
17000            self.previous_version_lifetime_check();
17001            outcome
17002        })();
17003        let more = self.transport_enabled
17004            && matches!(&outcome, SyncOutcome::Ok(report)
17005                if report.schema_floor.is_none()
17006                    && (!report.bootstrapping.is_empty()
17007                        || report.commits_applied > 0
17008                        || report.segment_rows_applied > 0
17009                        || !report.resets.is_empty()
17010                        || report.deferred_commits > 0
17011                        || self.sync_needed));
17012        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
17013            && before
17014                != self
17015                    .subs
17016                    .iter()
17017                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17018                    .collect::<Vec<_>>();
17019        crate::AppliedSyncRound::Complete {
17020            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
17021            controls: if self.transport_enabled {
17022                downloads.controls
17023            } else {
17024                Vec::new()
17025            },
17026            more,
17027            bootstrap_advanced,
17028        }
17029    }
17030
17031    pub fn sync_until_idle(
17032        &mut self,
17033        transport: &mut dyn Transport,
17034        max_rounds: Option<u32>,
17035    ) -> SyncOutcome {
17036        // §7.7: an explicit budget is a strict positive round count. Zero is a
17037        // caller error, not an exhaustion.
17038        if max_rounds == Some(0) {
17039            return SyncOutcome::Failed {
17040                details: None,
17041                error_code: "sync.invalid_request".into(),
17042                message: "maxRounds must be a positive integer".into(),
17043                recommended_action: None,
17044            };
17045        }
17046        let rounds = max_rounds.unwrap_or(20);
17047        let mut aggregate = SyncReport::default();
17048        let mut spent = 0;
17049        while spent < rounds {
17050            spent += 1;
17051            let before = self
17052                .subs
17053                .iter()
17054                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17055                .collect::<Vec<_>>();
17056            match self.sync(transport) {
17057                SyncOutcome::Failed {
17058                    error_code,
17059                    message,
17060                    details,
17061                    recommended_action,
17062                } => {
17063                    return SyncOutcome::Failed {
17064                        details,
17065                        error_code,
17066                        message,
17067                        recommended_action,
17068                    };
17069                }
17070                outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
17071                SyncOutcome::BudgetExhausted(_) => {
17072                    unreachable!("sync() never exhausts a round budget")
17073                }
17074                SyncOutcome::Ok(report) => {
17075                    if max_rounds.is_none()
17076                        && report.segment_rows_applied > 0
17077                        && !report.bootstrapping.is_empty()
17078                        && before
17079                            != self
17080                                .subs
17081                                .iter()
17082                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17083                                .collect::<Vec<_>>()
17084                    {
17085                        spent = 0;
17086                    }
17087                    // TS parity: a disabled transport or a schema floor ends
17088                    // the loop. The latest round still merges first.
17089                    let stop = !self.transport_enabled() || report.schema_floor.is_some();
17090                    aggregate.merge(&report);
17091                    if stop {
17092                        return SyncOutcome::Ok(aggregate);
17093                    }
17094                    // §4.5: pull again whenever the response contained
17095                    // commits or segments; resets re-bootstrap; a pending
17096                    // resume token continues paging (§4.7); deferred outbox
17097                    // commits and a raised sync-needed signal push on the next
17098                    // round.
17099                    let more = !report.bootstrapping.is_empty()
17100                        || report.commits_applied > 0
17101                        || report.segment_rows_applied > 0
17102                        || !report.resets.is_empty()
17103                        || report.deferred_commits > 0
17104                        || self.sync_needed;
17105                    if !more {
17106                        return SyncOutcome::Ok(aggregate);
17107                    }
17108                }
17109            }
17110        }
17111        // §7.7: partial success. The aggregate report is retained and the run
17112        // is explicitly not idle.
17113        SyncOutcome::BudgetExhausted(aggregate)
17114    }
17115
17116    fn process_response(
17117        &mut self,
17118        transport: &mut dyn Transport,
17119        response: Message,
17120        meta: &RequestMeta,
17121    ) -> SyncOutcome {
17122        #[cfg(feature = "bench-internals")]
17123        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
17124        let mut report = SyncReport {
17125            pushed: meta.pushed_ids.len() as u32,
17126            ..SyncReport::default()
17127        };
17128        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
17129            HashMap::new();
17130        let pushed_ids = meta
17131            .pushed_ids
17132            .iter()
17133            .map(String::as_str)
17134            .collect::<HashSet<_>>();
17135        let mut last_final_push_result_id: Option<String> = None;
17136        for frame in &response.frames {
17137            match frame {
17138                Frame::PushResultDetails {
17139                    client_commit_id,
17140                    entries,
17141                } => {
17142                    let details = rejection_details_by_commit
17143                        .entry(client_commit_id.clone())
17144                        .or_default();
17145                    for entry in entries {
17146                        let parsed = match RejectionDetails::parse(&entry.details.0) {
17147                            Ok(value) => value,
17148                            Err(message) => {
17149                                return SyncOutcome::Failed {
17150                                    details: None,
17151                                    error_code: "sync.invalid_request".to_owned(),
17152                                    message,
17153                                    recommended_action: None,
17154                                };
17155                            }
17156                        };
17157                        details.insert(entry.op_index, parsed);
17158                    }
17159                }
17160                Frame::PushResult {
17161                    client_commit_id,
17162                    status,
17163                    results,
17164                    ..
17165                } if pushed_ids.contains(client_commit_id.as_str())
17166                    && Self::push_result_is_final(*status, results) =>
17167                {
17168                    last_final_push_result_id = Some(client_commit_id.clone());
17169                }
17170                _ => {}
17171            }
17172        }
17173        if response.wire_version < 2 {
17174            return SyncOutcome::Failed {
17175                details: None,
17176                error_code: "client.invalid_host_response".to_owned(),
17177                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
17178                recommended_action: None,
17179            };
17180        }
17181        let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
17182            status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
17183        });
17184        let mut frames = response.frames.into_iter().peekable();
17185        match frames.next() {
17186            Some(Frame::RespHeader {
17187                required_schema_version,
17188                latest_schema_version,
17189                log_epoch,
17190                reset_required,
17191            }) => {
17192                let Some(log_epoch) = log_epoch else {
17193                    return SyncOutcome::Failed {
17194                        details: None,
17195                        error_code: "client.invalid_host_response".to_owned(),
17196                        message: "response header omits logEpoch".to_owned(),
17197                        recommended_action: None,
17198                    };
17199                };
17200                let Some(reset_required) = reset_required else {
17201                    return SyncOutcome::Failed {
17202                        details: None,
17203                        error_code: "client.invalid_host_response".to_owned(),
17204                        message: "response header omits resetRequired".to_owned(),
17205                        recommended_action: None,
17206                    };
17207                };
17208                if let Some(required) = required_schema_version {
17209                    // §1.6 schema-floor response: nothing else is processed;
17210                    // stop syncing and surface the upgrade requirement.
17211                    let floor = SchemaFloor {
17212                        required_schema_version: Some(required),
17213                        latest_schema_version,
17214                    };
17215                    self.set_schema_floor(Some(floor.clone()));
17216                    report.schema_floor = Some(floor);
17217                    return SyncOutcome::Ok(report);
17218                }
17219                if reset_required {
17220                    if frames.next().is_some() {
17221                        return SyncOutcome::Failed {
17222                            details: None,
17223                            error_code: "client.invalid_host_response".to_owned(),
17224                            message: "log-epoch reset response contains body frames".to_owned(),
17225                            recommended_action: None,
17226                        };
17227                    }
17228                    match self.run_log_epoch_reset(&log_epoch) {
17229                        Ok(resets) => {
17230                            report.resets = resets;
17231                            return SyncOutcome::Ok(report);
17232                        }
17233                        Err(message) => {
17234                            return SyncOutcome::Failed {
17235                                details: None,
17236                                error_code: "sync.local_corrupt".to_owned(),
17237                                message,
17238                                recommended_action: None,
17239                            };
17240                        }
17241                    }
17242                }
17243                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
17244                    return SyncOutcome::Failed {
17245                        details: None,
17246                        error_code: "client.invalid_host_response".to_owned(),
17247                        message: "server changed logEpoch without requiring a reset".to_owned(),
17248                        recommended_action: None,
17249                    };
17250                }
17251            }
17252            _ => {
17253                return SyncOutcome::Failed {
17254                    details: None,
17255                    error_code: "sync.invalid_request".to_owned(),
17256                    message: "response does not start with RESP_HEADER".to_owned(),
17257                    recommended_action: None,
17258                };
17259            }
17260        }
17261
17262        if let Err(message) = self.begin_coalesced_apply() {
17263            return SyncOutcome::Failed {
17264                details: None,
17265                error_code: "storage.failed".into(),
17266                message,
17267                recommended_action: None,
17268            };
17269        }
17270        let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
17271        while let Some(frame) = frames.next() {
17272            match frame {
17273                frame @ Frame::PushResult { .. } => {
17274                    let successful = matches!(
17275                        &frame,
17276                        Frame::PushResult {
17277                            status: PushStatus::Applied | PushStatus::Cached,
17278                            ..
17279                        }
17280                    );
17281                    let mut results = vec![frame];
17282                    if successful {
17283                        while matches!(
17284                            frames.peek(),
17285                            Some(Frame::PushResult {
17286                                status: PushStatus::Applied | PushStatus::Cached,
17287                                ..
17288                            })
17289                        ) {
17290                            if let Some(next) = frames.next() {
17291                                results.push(next);
17292                            }
17293                        }
17294                    }
17295                    if self
17296                        .handle_push_results(
17297                            &results,
17298                            &pushed_ids,
17299                            &rejection_details_by_commit,
17300                            &mut report,
17301                            last_final_push_result_id.as_deref(),
17302                        )
17303                        .is_err()
17304                    {
17305                        failure = Some((
17306                            "client.outcome_persistence_failed".to_owned(),
17307                            "local commit outcome could not be persisted".to_owned(),
17308                            None,
17309                            None,
17310                        ));
17311                        break;
17312                    }
17313                }
17314                Frame::PushResultDetails { .. } => {}
17315                Frame::SubStart {
17316                    id,
17317                    status,
17318                    reason_code,
17319                    effective_scopes,
17320                    bootstrap: _,
17321                } => {
17322                    let mut body = Vec::new();
17323                    let mut sub_end: Option<(i64, Option<String>)> = None;
17324                    for inner in frames.by_ref() {
17325                        match inner {
17326                            Frame::SubEnd {
17327                                next_cursor,
17328                                bootstrap_state,
17329                            } => {
17330                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
17331                                break;
17332                            }
17333                            Frame::Unknown { .. } => {}
17334                            other => body.push(other),
17335                        }
17336                    }
17337                    if let Err(SectionError::Abort(code, message)) = self.process_section(
17338                        transport,
17339                        &id,
17340                        status,
17341                        &reason_code,
17342                        effective_scopes,
17343                        body,
17344                        sub_end,
17345                        meta,
17346                        &mut report,
17347                        &coalesced_deletes,
17348                    ) {
17349                        failure = Some((code, message, None, None));
17350                        break;
17351                    }
17352                }
17353                Frame::Lease {
17354                    lease_id,
17355                    expires_at_ms,
17356                } => {
17357                    // §7.3.5: persist the opaque lease; a fresh lease clears
17358                    // any prior lease error (the outage/revocation is over).
17359                    self.set_lease_state(Some(LeaseState {
17360                        lease_id: Some(lease_id),
17361                        expires_at_ms: Some(expires_at_ms),
17362                        error_code: None,
17363                    }));
17364                }
17365                Frame::Error {
17366                    code,
17367                    message,
17368                    recommended_action,
17369                    details,
17370                    ..
17371                } => {
17372                    // §1.6: the whole request failed; already-completed
17373                    // subscriptions keep their applied data and cursors.
17374                    let recommended_action =
17375                        (!recommended_action.is_empty()).then_some(recommended_action);
17376                    let details = details
17377                        .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
17378                        .filter(Value::is_object);
17379                    failure = Some((code, message, recommended_action, details));
17380                    break;
17381                }
17382                Frame::Unknown { .. } => {}
17383                _ => {
17384                    failure = Some((
17385                        "sync.invalid_request".to_owned(),
17386                        "unexpected frame in response".to_owned(),
17387                        None,
17388                        None,
17389                    ));
17390                    break;
17391                }
17392            }
17393        }
17394
17395        let applied = match failure {
17396            Some(failure) => Err(failure),
17397            // Local apply boundaries already reconciled their affected overlay.
17398            // Refuse round success if any remaining dirty work fails to
17399            // reconcile. §4.8 E1: the push half may have drained commits that
17400            // pinned rows of a shrunk window unit, so retry deferred evictions.
17401            None => self
17402                .rebuild_overlay_if_dirty()
17403                .and_then(|()| self.drain_pending_evictions())
17404                .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
17405        };
17406        // §1.4 rule 4: frames completed before a failure stay committed; a
17407        // failed COMMIT of the coalesced transaction fails the round.
17408        if let Err(message) = self.finish_coalesced_apply() {
17409            return SyncOutcome::Failed {
17410                details: None,
17411                error_code: "storage.failed".into(),
17412                message,
17413                recommended_action: None,
17414            };
17415        }
17416        if let Err((error_code, message, recommended_action, details)) = applied {
17417            return SyncOutcome::Failed {
17418                details,
17419                error_code,
17420                message,
17421                recommended_action,
17422            };
17423        }
17424        self.ack_after_pull(transport);
17425        // §7.4.5: the reset is over once the first post-reset pull round
17426        // leaves no subscription mid-bootstrap — the tables are rebuilt.
17427        if self.upgrading && report.bootstrapping.is_empty() {
17428            self.set_upgrading(false);
17429        }
17430        SyncOutcome::Ok(report)
17431    }
17432
17433    // -- push results (§6.3, §7.2) ------------------------------------------------
17434
17435    fn handle_push_results(
17436        &mut self,
17437        frames: &[Frame],
17438        pushed_ids: &HashSet<&str>,
17439        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
17440        report: &mut SyncReport,
17441        last_final_push_result_id: Option<&str>,
17442    ) -> Result<(), String> {
17443        self.begin_observation("syncular_push_results")?;
17444        let previous_failed = self.failed_commits.clone();
17445        let previous_sync_needed = self.sync_needed;
17446        let conflict_count = self.conflicts.len();
17447        let rejection_count = self.rejections.len();
17448        let applied_count = report.applied.len();
17449        let rejected_count = report.rejected.len();
17450        let retryable_count = report.retryable.len();
17451        let was_dirty = self.overlay_dirty.snapshot();
17452        let mut removed = Vec::new();
17453        let mut batch = ChangeAccumulator::default();
17454        let persisted = (|| {
17455            for frame in frames {
17456                let Frame::PushResult {
17457                    client_commit_id,
17458                    status,
17459                    results,
17460                    commit_seq,
17461                } = frame
17462                else {
17463                    return Err("unexpected acknowledgement frame".to_owned());
17464                };
17465                if !pushed_ids.contains(client_commit_id.as_str()) {
17466                    continue;
17467                }
17468                let Some(index) = self
17469                    .outbox
17470                    .iter()
17471                    .position(|commit| commit.client_commit_id == *client_commit_id)
17472                else {
17473                    continue;
17474                };
17475                let status = *status;
17476                let rejection_details = rejection_details_by_commit.get(client_commit_id);
17477                let operations = self.outbox[index].ops.clone();
17478                match status {
17479                    PushStatus::Applied | PushStatus::Cached => {
17480                        // §7.2: a lost ack replays as `cached` — proceed as if the
17481                        // ack had arrived.
17482                        let sequence =
17483                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
17484                        let affected: HashSet<_> = operations
17485                            .iter()
17486                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
17487                            .collect();
17488                        let prior = self.acknowledged_operations(Some(&affected), None)?;
17489                        for (op_index, operation) in operations.iter().enumerate() {
17490                            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))
17491                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17492                            if delivered {
17493                                continue;
17494                            }
17495                            let table = self
17496                                .schema
17497                                .table(&operation.table)
17498                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
17499                            let mut intended: Map<String, Value> = self
17500                                .stored_row(table, &operation.row_id, true)?
17501                                .map(|(row, _)| {
17502                                    table
17503                                        .columns
17504                                        .iter()
17505                                        .zip(&row)
17506                                        .map(|(column, value)| {
17507                                            (column.name.clone(), column_value_to_json(value))
17508                                        })
17509                                        .collect()
17510                                })
17511                                .unwrap_or_default();
17512                            let mut scope_image = intended.clone();
17513                            for op in prior
17514                                .iter()
17515                                .chain(
17516                                    self.failed_commits
17517                                        .iter()
17518                                        .flat_map(|(commit, _)| &commit.ops),
17519                                )
17520                                .chain(
17521                                    self.outbox
17522                                        .iter()
17523                                        .take(index)
17524                                        .flat_map(|commit| &commit.ops),
17525                                )
17526                                .chain(operations.iter().take(op_index + 1))
17527                            {
17528                                if op.table != operation.table || op.row_id != operation.row_id {
17529                                    continue;
17530                                }
17531                                if let Some(values) = &op.values {
17532                                    intended.extend(values.clone());
17533                                } else {
17534                                    scope_image = intended.clone();
17535                                    intended.clear();
17536                                }
17537                            }
17538                            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)",
17539                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
17540                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
17541                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
17542                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17543                        }
17544                        let journal_results = results
17545                            .iter()
17546                            .map(|result| {
17547                                let op_index = match result {
17548                                    OpResult::Applied { op_index }
17549                                    | OpResult::Conflict { op_index, .. }
17550                                    | OpResult::Error { op_index, .. } => *op_index,
17551                                };
17552                                CommitOperationOutcome::Applied { op_index }
17553                            })
17554                            .collect::<Vec<_>>();
17555                        let outcome_status = if status == PushStatus::Applied {
17556                            CommitOutcomeStatus::Applied
17557                        } else {
17558                            CommitOutcomeStatus::Cached
17559                        };
17560                        let persisted = self
17561                            .persist_commit_outcome(
17562                                client_commit_id,
17563                                outcome_status,
17564                                &journal_results,
17565                                None,
17566                            )
17567                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
17568                        persisted?;
17569                        for operation in &operations {
17570                            self.overlay_dirty.table(&operation.table);
17571                        }
17572                        self.sync_needed = true;
17573                        batch.status = true;
17574                        batch.outcomes = true;
17575                    }
17576                    PushStatus::Rejected => {
17577                        if results.iter().any(|result| {
17578                            matches!(
17579                                result,
17580                                OpResult::Error {
17581                                    code,
17582                                    retryable: true,
17583                                    ..
17584                                } if code == "sync.idempotency_cache_miss"
17585                            )
17586                        }) {
17587                            // §6.3/§7.2: a serving failure, not an outcome — keep the
17588                            // exact commit queued for an identical retry.
17589                            report.retryable.push(client_commit_id.to_owned());
17590                            continue;
17591                        }
17592
17593                        let mut journal_results = Vec::with_capacity(results.len());
17594                        let mut conflicts = Vec::new();
17595                        let mut rejections = Vec::new();
17596                        for result in results {
17597                            match result {
17598                                OpResult::Applied { op_index } => {
17599                                    journal_results.push(CommitOperationOutcome::Applied {
17600                                        op_index: *op_index,
17601                                    });
17602                                }
17603                                OpResult::Conflict {
17604                                    op_index,
17605                                    code,
17606                                    message,
17607                                    server_version,
17608                                    server_row,
17609                                    conflict_columns: conflict_columns_bitmap,
17610                                } => {
17611                                    let operation = operations
17612                                        .get(*op_index as usize)
17613                                        .map(CommitOperation::from);
17614                                    let (table, row_id) = operation
17615                                        .as_ref()
17616                                        .map(|op| (op.table.clone(), op.row_id.clone()))
17617                                        .unwrap_or_default();
17618                                    let server_row_json = self
17619                                        .schema
17620                                        .table(&table)
17621                                        .and_then(|t| {
17622                                            decode_row_bytes(t, server_row, &self.encryption)
17623                                                .ok()
17624                                                .map(|row| (t, row))
17625                                        })
17626                                        .map(|(t, row)| {
17627                                            let mut map = Map::new();
17628                                            for (i, column) in t.columns.iter().enumerate() {
17629                                                map.insert(
17630                                                    column.name.clone(),
17631                                                    column_value_to_json(
17632                                                        row.get(i).unwrap_or(&None),
17633                                                    ),
17634                                                );
17635                                            }
17636                                            map
17637                                        })
17638                                        .unwrap_or_default();
17639                                    // §6.3: decode the presence-layout bitmap
17640                                    // into the marked column names.
17641                                    let conflict_columns = self
17642                                        .schema
17643                                        .table(&table)
17644                                        .map(|t| {
17645                                            t.columns
17646                                                .iter()
17647                                                .enumerate()
17648                                                .filter(|(index, _)| {
17649                                                    conflict_columns_bitmap
17650                                                        .get(index / 8)
17651                                                        .is_some_and(|byte| {
17652                                                            byte & (1 << (index % 8)) != 0
17653                                                        })
17654                                                })
17655                                                .map(|(_, column)| column.name.clone())
17656                                                .collect::<Vec<_>>()
17657                                        })
17658                                        .unwrap_or_default();
17659                                    let conflict = ConflictRecord {
17660                                        client_commit_id: client_commit_id.to_owned(),
17661                                        op_index: *op_index,
17662                                        table,
17663                                        row_id,
17664                                        code: code.clone(),
17665                                        message: message.clone(),
17666                                        server_version: *server_version,
17667                                        server_row: server_row_json,
17668                                        conflict_columns,
17669                                        operation,
17670                                    };
17671                                    journal_results.push(CommitOperationOutcome::Conflict {
17672                                        conflict: conflict.clone(),
17673                                    });
17674                                    conflicts.push(conflict);
17675                                }
17676                                OpResult::Error {
17677                                    op_index,
17678                                    code,
17679                                    message,
17680                                    retryable,
17681                                } => {
17682                                    let rejection = RejectionRecord {
17683                                        client_commit_id: client_commit_id.to_owned(),
17684                                        op_index: *op_index,
17685                                        code: code.clone(),
17686                                        message: message.clone(),
17687                                        retryable: *retryable,
17688                                        details: rejection_details
17689                                            .and_then(|details| details.get(op_index))
17690                                            .cloned(),
17691                                        operation: operations
17692                                            .get(*op_index as usize)
17693                                            .map(CommitOperation::from),
17694                                    };
17695                                    journal_results.push(CommitOperationOutcome::Error {
17696                                        rejection: rejection.clone(),
17697                                    });
17698                                    rejections.push(rejection);
17699                                }
17700                            }
17701                        }
17702                        if self.retain_failed_commits {
17703                            self.retain_failed_commit(client_commit_id, &operations)?;
17704                        }
17705                        let outcome_status = if conflicts.is_empty() {
17706                            CommitOutcomeStatus::Rejected
17707                        } else {
17708                            CommitOutcomeStatus::Conflict
17709                        };
17710                        let persisted = self
17711                            .persist_commit_outcome(
17712                                client_commit_id,
17713                                outcome_status,
17714                                &journal_results,
17715                                Some(&operations),
17716                            )
17717                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
17718                        persisted?;
17719                        batch.conflicts = !conflicts.is_empty();
17720                        batch.rejections = !rejections.is_empty();
17721                        batch.status = true;
17722                        batch.outcomes = true;
17723                        self.conflicts.extend(conflicts);
17724                        self.rejections.extend(rejections);
17725                        for operation in &operations {
17726                            self.overlay_dirty.table(&operation.table);
17727                        }
17728                    }
17729                }
17730                removed.push((index, self.outbox.remove(index)));
17731                if batch.status {
17732                    for operation in &operations {
17733                        if !self.record_row_scopes(
17734                            &mut batch,
17735                            &operation.table,
17736                            &operation.row_id,
17737                            false,
17738                        ) {
17739                            batch.table(&operation.table);
17740                        }
17741                    }
17742                }
17743                match status {
17744                    PushStatus::Applied | PushStatus::Cached => {
17745                        report.applied.push(client_commit_id.to_owned())
17746                    }
17747                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
17748                }
17749            }
17750            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)) {
17751                self.prune_commit_outcomes()?;
17752            }
17753            self.rebuild_overlay_if_dirty()?;
17754            self.finish_observation("syncular_push_results", batch)
17755        })();
17756        if let Err(error) = persisted {
17757            self.rollback_observation("syncular_push_results");
17758            self.failed_commits = previous_failed;
17759            self.sync_needed = previous_sync_needed;
17760            for (index, commit) in removed.into_iter().rev() {
17761                self.outbox.insert(index, commit);
17762            }
17763            self.conflicts.truncate(conflict_count);
17764            self.rejections.truncate(rejection_count);
17765            self.overlay_dirty.restore(was_dirty.clone());
17766            report.applied.truncate(applied_count);
17767            report.rejected.truncate(rejected_count);
17768            report.retryable.truncate(retryable_count);
17769            return Err(error);
17770        }
17771        if !previous_sync_needed && self.sync_needed {
17772            self.sync_intent_queue.push_back(SyncIntent::Interactive);
17773        }
17774        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
17775        Ok(())
17776    }
17777
17778    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
17779        status != PushStatus::Rejected
17780            || !results.iter().any(|result| {
17781                matches!(
17782                    result,
17783                    OpResult::Error {
17784                        code,
17785                        retryable: true,
17786                        ..
17787                    } if code == "sync.idempotency_cache_miss"
17788                )
17789            })
17790    }
17791
17792    // -- subscription sections ------------------------------------------------------
17793
17794    #[allow(clippy::too_many_arguments)]
17795    fn process_section(
17796        &mut self,
17797        transport: &mut dyn Transport,
17798        id: &str,
17799        status: SubStatus,
17800        reason_code: &str,
17801        effective_scopes: Vec<(String, Vec<String>)>,
17802        body: Vec<Frame>,
17803        sub_end: Option<(i64, Option<String>)>,
17804        meta: &RequestMeta,
17805        report: &mut SyncReport,
17806        coalesced_deletes: &HashSet<(i64, String, String)>,
17807    ) -> Result<(), SectionError> {
17808        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
17809            return Ok(()); // unknown echo: ignore
17810        };
17811        if status != SubStatus::Active && sub_end.is_none() {
17812            return Err(SectionError::Abort(
17813                "sync.invalid_request".into(),
17814                "subscription section without SUB_END".into(),
17815            ));
17816        }
17817        match status {
17818            SubStatus::Revoked => {
17819                self.begin_observation("syncular_revocation")
17820                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17821                let previous = self.subs[sub_index].clone();
17822                let was_dirty = self.overlay_dirty.snapshot();
17823                let previous_outbox = self.outbox.clone();
17824                let previous_failed = self.failed_commits.clone();
17825                let previous_rejections = self.rejections.clone();
17826                let previous_conflicts = self.conflicts.clone();
17827                let revoked_count = report.revoked.len();
17828                let failed_count = report.failed.len();
17829                let result = (|| {
17830                    let mut batch = ChangeAccumulator::default();
17831                    let registered = self.window_unit_by_sub_id(id);
17832                    // §3.3: stop pulling, purge exactly the last effective grant.
17833                    let (table, effective) = {
17834                        let sub = &self.subs[sub_index];
17835                        (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17836                    };
17837                    let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
17838                        SectionError::Abort(
17839                            "sync.unknown_table".to_owned(),
17840                            "revoked scope references an unknown table".to_owned(),
17841                        )
17842                    })?;
17843                    let dropped = self
17844                        .drop_failed_commits(|name, values| {
17845                            name == table
17846                                && !effective.is_empty()
17847                                && effective.iter().all(|(variable, allowed)| {
17848                                    scoped_table
17849                                        .scope_column(variable)
17850                                        .and_then(|column| values.get(column))
17851                                        .and_then(Value::as_str)
17852                                        .is_some_and(|value| {
17853                                            allowed.iter().any(|allowed| allowed == value)
17854                                        })
17855                                })
17856                        })
17857                        .map_err(|message| {
17858                            SectionError::Abort("storage.failed".to_owned(), message)
17859                        })?;
17860                    for commit in dropped {
17861                        batch.conflicts = true;
17862                        batch.rejections = true;
17863                        batch.outcomes = true;
17864                        for operation in commit.ops {
17865                            batch.table(&operation.table);
17866                        }
17867                    }
17868                    let purged = self.purge_scope_rows(&table, &effective);
17869                    match purged {
17870                        Ok(()) => {
17871                            self.record_scope_map(&mut batch, &table, &effective);
17872                            let sub = &mut self.subs[sub_index];
17873                            sub.state = SubState::Revoked;
17874                            sub.reason_code = Some(if reason_code.is_empty() {
17875                                "sync.scope_revoked".to_owned()
17876                            } else {
17877                                reason_code.to_owned()
17878                            });
17879                            report.revoked.push(id.to_owned());
17880                            let doomed_effective = effective;
17881                            let sub_table = table;
17882                            self.persist_sub(&self.subs[sub_index].clone())
17883                                .map_err(|message| {
17884                                    SectionError::Abort("storage.failed".into(), message)
17885                                })?;
17886                            let dropped = self
17887                                .drop_doomed_outbox(&sub_table, &doomed_effective)
17888                                .map_err(|message| {
17889                                    SectionError::Abort("storage.failed".to_owned(), message)
17890                                })?;
17891                            if dropped {
17892                                batch.status = true;
17893                                batch.rejections = true;
17894                                batch.outcomes = true;
17895                            }
17896                            // §5.9.7 B2: revocation deletes now-unauthorized blob
17897                            // bodies (evicted ≠ revoked).
17898                            self.delete_unreferenced_cached_blobs().map_err(|message| {
17899                                SectionError::Abort("storage.failed".into(), message)
17900                            })?;
17901                        }
17902                        Err(()) => {
17903                            // §3.3 fail closed: no local mapping — never clear by
17904                            // approximation; fatal configuration error.
17905                            let sub = &mut self.subs[sub_index];
17906                            sub.state = SubState::Failed;
17907                            sub.reason_code = Some("sync.scope_revoked".to_owned());
17908                            report.failed.push(id.to_owned());
17909                            self.persist_sub(&self.subs[sub_index].clone())
17910                                .map_err(|message| {
17911                                    SectionError::Abort("storage.failed".into(), message)
17912                                })?;
17913                        }
17914                    }
17915                    if let Some((base_key, unit)) = registered {
17916                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
17917                    }
17918                    self.rebuild_overlay_if_dirty()
17919                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17920                    self.finish_observation("syncular_revocation", batch)
17921                        .map_err(|message| {
17922                            SectionError::Abort("storage.failed".to_owned(), message)
17923                        })?;
17924                    Ok(())
17925                })();
17926                if result.is_err() {
17927                    self.rollback_observation("syncular_revocation");
17928                    self.subs[sub_index] = previous;
17929                    self.overlay_dirty.restore(was_dirty.clone());
17930                    self.outbox = previous_outbox;
17931                    self.failed_commits = previous_failed;
17932                    self.rejections = previous_rejections;
17933                    self.conflicts = previous_conflicts;
17934                    report.revoked.truncate(revoked_count);
17935                    report.failed.truncate(failed_count);
17936                }
17937                result
17938            }
17939            SubStatus::Reset => {
17940                self.begin_observation("syncular_reset")
17941                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17942                let previous = self.subs[sub_index].clone();
17943                let was_dirty = self.overlay_dirty.snapshot();
17944                let reset_count = report.resets.len();
17945                let result = (|| {
17946                    let mut batch = ChangeAccumulator::default();
17947                    let registered = self.window_unit_by_sub_id(id);
17948                    // §4.6: discard cursor + bootstrap state, keep local rows —
17949                    // reset is a staleness signal, not a purge signal.
17950                    let sub = &mut self.subs[sub_index];
17951                    sub.cursor = -1;
17952                    sub.bootstrap_state = None;
17953                    sub.reason_code = Some(if reason_code.is_empty() {
17954                        "sync.cursor_expired".to_owned()
17955                    } else {
17956                        reason_code.to_owned()
17957                    });
17958                    report.resets.push(id.to_owned());
17959                    self.persist_sub(&self.subs[sub_index].clone())
17960                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17961                    if let Some((base_key, unit)) = registered {
17962                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
17963                    }
17964                    self.finish_observation("syncular_reset", batch)
17965                        .map_err(|message| {
17966                            SectionError::Abort("storage.failed".to_owned(), message)
17967                        })?;
17968                    Ok(())
17969                })();
17970                if result.is_err() {
17971                    self.rollback_observation("syncular_reset");
17972                    self.subs[sub_index] = previous;
17973                    self.overlay_dirty.restore(was_dirty.clone());
17974                    report.resets.truncate(reset_count);
17975                }
17976                result
17977            }
17978            SubStatus::Active => {
17979                let fresh = meta
17980                    .fresh
17981                    .iter()
17982                    .find(|(fid, _)| fid == id)
17983                    .map(|(_, fresh)| *fresh)
17984                    .unwrap_or(false);
17985                let previous = self.subs[sub_index].clone();
17986                self.subs[sub_index].effective = Some(effective_scopes);
17987                let outcome = self
17988                    .apply_section_body(
17989                        transport,
17990                        sub_index,
17991                        body,
17992                        fresh,
17993                        meta,
17994                        report,
17995                        coalesced_deletes,
17996                    )
17997                    .and_then(|()| {
17998                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
17999                            SectionError::Abort(
18000                                "sync.invalid_request".into(),
18001                                "subscription section without SUB_END".into(),
18002                            )
18003                        })?;
18004                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
18005                            .map_err(|message| {
18006                                SectionError::Abort("storage.failed".into(), message)
18007                            })
18008                    });
18009                match outcome {
18010                    Ok(()) => {
18011                        if self.subs[sub_index].bootstrap_state.is_some() {
18012                            report.bootstrapping.push(id.to_owned());
18013                        }
18014                        Ok(())
18015                    }
18016                    Err(SectionError::FailClosed) => {
18017                        self.subs[sub_index] = previous.clone();
18018                        self.begin_observation("syncular_section_failure")
18019                            .map_err(|message| {
18020                                SectionError::Abort("storage.failed".into(), message)
18021                            })?;
18022                        let persisted = (|| {
18023                            let mut batch = ChangeAccumulator::default();
18024                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
18025                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
18026                            }
18027                            self.subs[sub_index].state = SubState::Failed;
18028                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
18029                            self.persist_sub(&self.subs[sub_index])?;
18030                            self.finish_observation("syncular_section_failure", batch)
18031                        })();
18032                        if let Err(message) = persisted {
18033                            self.rollback_observation("syncular_section_failure");
18034                            self.subs[sub_index] = previous;
18035                            return Err(SectionError::Abort("storage.failed".into(), message));
18036                        }
18037                        report.failed.push(id.to_owned());
18038                        Ok(())
18039                    }
18040                    Err(error) => {
18041                        // Frame/block transactions already completed. Preserve their
18042                        // rows and revisions, but leave this subscription cursor unchanged.
18043                        self.subs[sub_index] = previous;
18044                        Err(error)
18045                    }
18046                }
18047            }
18048        }
18049    }
18050
18051    fn apply_sub_end(
18052        &mut self,
18053        sub_index: usize,
18054        cursor: i64,
18055        bootstrap_state: Option<String>,
18056    ) -> Result<(), String> {
18057        #[cfg(feature = "bench-internals")]
18058        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
18059        let previous = self.subs[sub_index].clone();
18060        self.begin_observation("syncular_sub_end")?;
18061        let persisted = (|| {
18062            let mut batch = ChangeAccumulator::default();
18063            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
18064                && cursor >= 0
18065                && bootstrap_state.is_none();
18066            if completed {
18067                let table = self
18068                    .schema
18069                    .table(&previous.table)
18070                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
18071                let effective = previous.effective.as_deref().unwrap_or(&[]);
18072                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
18073                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18074                let rows = statement
18075                    .query_map(rusqlite::params![previous.table, cursor], |row| {
18076                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18077                    })
18078                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18079                let mut covered = HashSet::new();
18080                for row in rows {
18081                    let (id, intent) =
18082                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18083                    let values: Map<String, Value> = serde_json::from_str(&intent)
18084                        .map_err(|_| "sync.local_corrupt".to_owned())?;
18085                    if !effective.is_empty()
18086                        && effective.iter().all(|(variable, allowed)| {
18087                            table
18088                                .scope_column(variable)
18089                                .and_then(|column| values.get(column))
18090                                .is_some_and(|value| {
18091                                    allowed.contains(
18092                                        &value
18093                                            .as_str()
18094                                            .map_or_else(|| value.to_string(), str::to_owned),
18095                                    )
18096                                })
18097                        })
18098                    {
18099                        covered.insert(id);
18100                    }
18101                }
18102                drop(statement);
18103                for id in covered {
18104                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
18105                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18106                    self.overlay_dirty.table(&previous.table);
18107                    batch.table(&previous.table);
18108                }
18109                self.rebuild_overlay_if_dirty()?;
18110                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
18111                    batch.window(&base_key, &previous.table, &unit);
18112                }
18113            }
18114            let sub = &mut self.subs[sub_index];
18115            sub.cursor = cursor;
18116            sub.bootstrap_state = bootstrap_state;
18117            // §4.6: a completed (or continuing) active end clears the reset /
18118            // revocation reason, matching the TS core's active-state persist.
18119            sub.reason_code = None;
18120            sub.synced_once = true;
18121            self.persist_sub(&self.subs[sub_index])?;
18122            self.finish_observation("syncular_sub_end", batch)
18123        })();
18124        if persisted.is_err() {
18125            self.rollback_observation("syncular_sub_end");
18126            self.subs[sub_index] = previous;
18127        }
18128        persisted
18129    }
18130
18131    #[allow(clippy::too_many_arguments)]
18132    fn apply_section_body(
18133        &mut self,
18134        transport: &mut dyn Transport,
18135        sub_index: usize,
18136        body: Vec<Frame>,
18137        fresh: bool,
18138        meta: &RequestMeta,
18139        report: &mut SyncReport,
18140        coalesced_deletes: &HashSet<(i64, String, String)>,
18141    ) -> Result<(), SectionError> {
18142        let mut saw_segment = false;
18143        for frame in body {
18144            match frame {
18145                Frame::Commit {
18146                    tables,
18147                    changes,
18148                    commit_seq,
18149                    ..
18150                } => {
18151                    if self
18152                        .apply_commit_frame_coalesced(
18153                            &tables,
18154                            changes,
18155                            commit_seq,
18156                            coalesced_deletes,
18157                        )
18158                        .map_err(|(c, m)| SectionError::Abort(c, m))?
18159                    {
18160                        report.commits_applied += 1;
18161                    }
18162                }
18163                Frame::SegmentInline { payload } => {
18164                    let segment = decode_rows_segment(&payload)
18165                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
18166                    self.progress.update(|p| {
18167                        p.phase = ProgressPhase::Import;
18168                        p.subscription_id = Some(self.subs[sub_index].id.clone());
18169                        p.table = Some(self.subs[sub_index].table.clone());
18170                        p.segment_id = None;
18171                        p.bytes_received = payload.len() as u64;
18172                        p.bytes_total = Some(payload.len() as u64);
18173                        p.rows_processed = 0;
18174                        p.rows_total =
18175                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
18176                    });
18177                    let first = !saw_segment;
18178                    saw_segment = true;
18179                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18180                    report.segment_rows_applied += applied;
18181                }
18182                Frame::SegmentRef {
18183                    segment_id,
18184                    byte_length,
18185                    media_type,
18186                    table,
18187                    row_count,
18188                    as_of_commit_seq,
18189                    scope_digest,
18190                    row_cursor,
18191                    next_row_cursor,
18192                    url,
18193                    url_expires_at_ms,
18194                    ..
18195                } => {
18196                    // §4.2: reject a descriptor whose mediaType was not
18197                    // advertised — never skip or guess.
18198                    let advertised = match media_type {
18199                        MediaType::Rows => {
18200                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
18201                                || meta.accept & ACCEPT_INLINE_ROWS != 0
18202                        }
18203                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
18204                    };
18205                    if !advertised {
18206                        return Err(SectionError::Abort(
18207                            "sync.invalid_request".to_owned(),
18208                            format!(
18209                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
18210                                media_type.name()
18211                            ),
18212                        ));
18213                    }
18214                    self.progress.update(|p| {
18215                        p.phase = ProgressPhase::Download;
18216                        p.subscription_id = Some(self.subs[sub_index].id.clone());
18217                        p.table = Some(table.clone());
18218                        p.segment_id = Some(segment_id.clone());
18219                        p.bytes_received = 0;
18220                        p.bytes_total = u64::try_from(byte_length).ok();
18221                        p.rows_processed = 0;
18222                        p.rows_total = u64::try_from(row_count).ok();
18223                    });
18224                    let observer = self.progress.clone();
18225                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
18226                    let bytes = if let Some(url) = url {
18227                        // §5.4: a url-carrying descriptor MUST be fetched
18228                        // from that URL; failure invalidates the whole
18229                        // descriptor (no fall-through to §5.5 — re-pull
18230                        // recovers, §1.4 rule 5).
18231                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
18232                            return Err(SectionError::Abort(
18233                                "sync.invalid_request".to_owned(),
18234                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
18235                                    .to_owned(),
18236                            ));
18237                        }
18238                        // §5.4: MUST NOT start a fetch at/past expiry.
18239                        if url_expires_at_ms.is_some_and(|exp| {
18240                            exp <= transport
18241                                .segment_fetch_started_at(&url)
18242                                .unwrap_or_else(|| self.clock_now_ms())
18243                        }) {
18244                            return Err(SectionError::Abort(
18245                                "sync.segment_expired".to_owned(),
18246                                format!(
18247                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
18248                                ),
18249                            ));
18250                        }
18251                        transport
18252                            .fetch_url(&url, &mut on_progress)
18253                            .map_err(|e| self.segment_fetch_failed(e))?
18254                    } else {
18255                        let requested_scopes_json =
18256                            canonical_scope_json(&self.subs[sub_index].requested);
18257                        transport
18258                            .download_segment(
18259                                &SegmentRequest {
18260                                    segment_id: segment_id.clone(),
18261                                    table,
18262                                    requested_scopes_json,
18263                                },
18264                                &mut on_progress,
18265                            )
18266                            .map_err(|e| self.segment_fetch_failed(e))?
18267                    };
18268                    // §5.1: verify the content address before applying.
18269                    on_progress(bytes.len() as u64);
18270                    let digest = Sha256::digest(&bytes);
18271                    let expected = segment_id
18272                        .strip_prefix("sha256:")
18273                        .unwrap_or(segment_id.as_str());
18274                    if bytes_to_hex(&digest) != expected {
18275                        // §5.1: the client discards the segment and re-pulls.
18276                        self.schedule_background_retry();
18277                        return Err(SectionError::Abort(
18278                            "sync.invalid_request".to_owned(),
18279                            "segment bytes do not match the content address (§5.1)".to_owned(),
18280                        ));
18281                    }
18282                    self.progress.update(|p| p.phase = ProgressPhase::Import);
18283                    if media_type == MediaType::Sqlite {
18284                        // §5.3: images are whole-table — a paged sqlite
18285                        // descriptor is invalid.
18286                        if row_cursor.is_some() || next_row_cursor.is_some() {
18287                            return Err(SectionError::Abort(
18288                                "sync.invalid_request".to_owned(),
18289                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
18290                                    .to_owned(),
18291                            ));
18292                        }
18293                        let first = !saw_segment;
18294                        saw_segment = true;
18295                        let applied = self.apply_sqlite_segment(
18296                            sub_index,
18297                            &bytes,
18298                            fresh && first,
18299                            row_count,
18300                            as_of_commit_seq,
18301                            &scope_digest,
18302                        )?;
18303                        report.segment_rows_applied += applied;
18304                    } else {
18305                        let segment = decode_rows_segment(&bytes).map_err(|e| {
18306                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
18307                        })?;
18308                        let first = row_cursor.is_none();
18309                        saw_segment = true;
18310                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18311                        report.segment_rows_applied += applied;
18312                    }
18313                }
18314                Frame::Unknown { .. } => {}
18315                Frame::Error { code, message, .. } => {
18316                    return Err(SectionError::Abort(code, message))
18317                }
18318                _ => {
18319                    return Err(SectionError::Abort(
18320                        "sync.invalid_request".to_owned(),
18321                        "unexpected frame inside a subscription section".to_owned(),
18322                    ));
18323                }
18324            }
18325        }
18326        Ok(())
18327    }
18328
18329    /// Apply one commit frame after dropping a scope move's duplicate delete
18330    /// when the same commit carries the row's upsert in another applied
18331    /// section (§2.2). A frame that carried both itself is untouched.
18332    fn apply_commit_frame_coalesced(
18333        &mut self,
18334        tables: &[String],
18335        changes: Vec<ssp2::model::Change>,
18336        commit_seq: i64,
18337        coalesced_deletes: &HashSet<(i64, String, String)>,
18338    ) -> Result<bool, (String, String)> {
18339        let changes: Vec<ssp2::model::Change> = changes
18340            .into_iter()
18341            .filter(|change| {
18342                if change.op != Op::Delete {
18343                    return true;
18344                }
18345                let Some(table) = tables.get(change.table_index as usize) else {
18346                    return true;
18347                };
18348                !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
18349            })
18350            .collect();
18351        if changes.is_empty() {
18352            return Ok(false);
18353        }
18354        self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
18355        Ok(true)
18356    }
18357
18358    // Both pull and realtime frames use the same durable observation boundary.
18359    fn apply_commit_frame(
18360        &mut self,
18361        tables: &[String],
18362        changes: &[ssp2::model::Change],
18363        commit_seq: Option<i64>,
18364    ) -> Result<(), (String, String)> {
18365        #[cfg(feature = "bench-internals")]
18366        let _phase = self.benchmark_phases.start(Phase::CommitApply);
18367        let was_dirty = self.overlay_dirty.snapshot();
18368        self.coalesce_unit(changes.len())
18369            .and_then(|()| self.begin_observation("syncular_commit"))
18370            .map_err(|message| ("storage.failed".into(), message))?;
18371        let applied = (|| {
18372            let mut batch = ChangeAccumulator::default();
18373            self.record_commit_changes(&mut batch, tables, changes);
18374            if let Some(sequence) = commit_seq {
18375                for change in changes {
18376                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
18377                        (
18378                            "sync.invalid_request".to_owned(),
18379                            "change tableIndex out of range".to_owned(),
18380                        )
18381                    })?;
18382                    if self
18383                        .outbox
18384                        .iter()
18385                        .flat_map(|commit| &commit.ops)
18386                        .any(|op| op.table == *table && op.row_id == change.row_id)
18387                    {
18388                        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])
18389                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18390                    }
18391                    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])
18392                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18393                }
18394            }
18395            self.apply_commit_changes(tables, changes)?;
18396            self.rebuild_overlay_if_dirty()
18397                .map_err(|message| ("storage.failed".into(), message))?;
18398            self.finish_observation("syncular_commit", batch)
18399                .map_err(|message| ("storage.failed".into(), message))
18400        })();
18401        if applied.is_err() {
18402            self.rollback_observation("syncular_commit");
18403            self.overlay_dirty.restore(was_dirty.clone());
18404        }
18405        applied
18406    }
18407
18408    fn apply_commit_changes(
18409        &mut self,
18410        tables: &[String],
18411        changes: &[ssp2::model::Change],
18412    ) -> Result<(), (String, String)> {
18413        // A clean overlay can reconcile changed primary keys independently
18414        // when their tables have no secondary unique constraints. Such a
18415        // constraint can change whether another row's pending write succeeds,
18416        // so those frames still require the complete FIFO replay below.
18417        let mirror_visible = !self.overlay_dirty.get()
18418            && self.failed_commits.is_empty()
18419            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
18420                || changes.iter().all(|change| {
18421                    tables
18422                        .get(change.table_index as usize)
18423                        .and_then(|name| self.schema.table(name))
18424                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
18425                }));
18426        for change in changes {
18427            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
18428                (
18429                    "sync.invalid_request".to_owned(),
18430                    "change tableIndex out of range".to_owned(),
18431                )
18432            })?;
18433            let table = self.schema.table(table_name).ok_or_else(|| {
18434                (
18435                    "sync.schema_mismatch".to_owned(),
18436                    format!("change targets unknown table {table_name:?}"),
18437                )
18438            })?;
18439            match change.op {
18440                Op::Upsert => {
18441                    let payload = change.row.as_ref().ok_or_else(|| {
18442                        (
18443                            "sync.invalid_request".to_owned(),
18444                            "upsert change without row payload".to_owned(),
18445                        )
18446                    })?;
18447                    // §5.11: decrypt encrypted columns on apply.
18448                    let row = {
18449                        #[cfg(feature = "bench-internals")]
18450                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
18451                        decode_row_bytes(table, payload, &self.encryption)
18452                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
18453                    };
18454                    let version = change.row_version.unwrap_or(0);
18455                    let table_name = table.name.clone();
18456                    self.write_base_row(&table_name, &row, version)
18457                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18458                    if mirror_visible {
18459                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
18460                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18461                    }
18462                }
18463                Op::Delete => {
18464                    self.delete_base_row(table_name, &change.row_id)
18465                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18466                    if mirror_visible {
18467                        let sql = format!(
18468                            "DELETE FROM {} WHERE {}",
18469                            visible_table(table_name),
18470                            row_id_predicate(table)
18471                        );
18472                        self.conn
18473                            .prepare_cached(&sql)
18474                            .and_then(|mut statement| {
18475                                statement.execute(rusqlite::params![change.row_id])
18476                            })
18477                            .map_err(|error| {
18478                                ("sync.invalid_request".to_owned(), error.to_string())
18479                            })?;
18480                    }
18481                }
18482            }
18483        }
18484        if mirror_visible {
18485            {
18486                let changed_rows: HashSet<(&str, &str)> = changes
18487                    .iter()
18488                    .map(|change| {
18489                        (
18490                            tables[change.table_index as usize].as_str(),
18491                            change.row_id.as_str(),
18492                        )
18493                    })
18494                    .collect();
18495                let acknowledged = self
18496                    .acknowledged_operations(Some(&changed_rows), None)
18497                    .map_err(|error| ("storage.failed".to_owned(), error))?;
18498                self.apply_outbox_ops(&acknowledged)
18499                    .map_err(|message| ("storage.failed".into(), message))?;
18500                self.apply_outbox_ops(
18501                    self.outbox
18502                        .iter()
18503                        .flat_map(|commit| &commit.ops)
18504                        .filter(|op| {
18505                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
18506                        }),
18507                )
18508                .map_err(|message| ("storage.failed".into(), message))?;
18509            }
18510            self.overlay_dirty.set(false);
18511        }
18512        Ok(())
18513    }
18514
18515    /// §5.6 segment application: validate against the generated schema,
18516    /// clear the grant on a fresh bootstrap's first page (fail closed
18517    /// without a mapping), then replace-or-upsert each row with its
18518    /// segment-carried server version (§5.2).
18519    fn apply_segment(
18520        &mut self,
18521        sub_index: usize,
18522        segment: &RowsSegment,
18523        first_fresh_page: bool,
18524    ) -> Result<u32, SectionError> {
18525        let (sub_table, effective) = {
18526            let sub = &self.subs[sub_index];
18527            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18528        };
18529        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
18530            SectionError::Abort(
18531                "sync.schema_mismatch".to_owned(),
18532                format!("subscription table {sub_table:?} is not in the client schema"),
18533            )
18534        })?;
18535        // §5.2: the column table validates against the generated schema —
18536        // order, names, types, nullability; mismatch is fatal. §5.11: the
18537        // server sends the WIRE types (bytes for an encrypted column), so
18538        // validate against wire_columns.
18539        let matches = segment.table == table.name
18540            && segment.schema_version == self.schema.version
18541            && segment.columns.len() == table.wire_columns.len()
18542            && segment
18543                .columns
18544                .iter()
18545                .zip(table.wire_columns.iter())
18546                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
18547        if !matches {
18548            return Err(SectionError::Abort(
18549                "sync.schema_mismatch".to_owned(),
18550                "segment column table does not match the generated schema (§5.2)".to_owned(),
18551            ));
18552        }
18553        let mut applied = 0u32;
18554        let empty = Vec::new();
18555        let blocks = if segment.blocks.is_empty() {
18556            std::slice::from_ref(&empty)
18557        } else {
18558            &segment.blocks
18559        };
18560        for (index, block) in blocks.iter().enumerate() {
18561            let clear = first_fresh_page && index == 0;
18562            if !clear && block.is_empty() {
18563                continue;
18564            }
18565            let was_dirty = self.overlay_dirty.snapshot();
18566            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18567                && self
18568                    .outbox
18569                    .iter()
18570                    .flat_map(|commit| &commit.ops)
18571                    .any(|op| op.table == table.name);
18572            let mirror_visible = !reconcile_pending
18573                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18574            self.coalesce_unit(block.len())
18575                .and_then(|()| self.begin_observation("syncular_segment_block"))
18576                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18577            let outcome = (|| {
18578                self.rebuild_overlay_if_dirty()
18579                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18580                let mut changed_keys = Vec::new();
18581                let mut batch = ChangeAccumulator::default();
18582                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
18583                    batch.table(&table.name);
18584                }
18585                if clear {
18586                    self.purge_scope_rows(&table.name, &effective)
18587                        .map_err(|()| SectionError::FailClosed)?;
18588                }
18589                for (row_index, row) in block.iter().enumerate() {
18590                    let decrypted;
18591                    let values = if table.has_encrypted_columns() {
18592                        let mut values = row.values.clone();
18593                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
18594                            .map_err(|message| {
18595                            SectionError::Abort("client.decrypt_failed".into(), message)
18596                        })?;
18597                        decrypted = values;
18598                        &decrypted
18599                    } else {
18600                        &row.values
18601                    };
18602                    self.write_base_row(&table.name, values, row.server_version)
18603                        .map_err(|message| {
18604                            SectionError::Abort("sync.invalid_request".into(), message)
18605                        })?;
18606                    if reconcile_pending {
18607                        changed_keys.push(
18608                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
18609                                |message| SectionError::Abort("storage.failed".into(), message),
18610                            )?,
18611                        );
18612                    }
18613                    if mirror_visible {
18614                        self.write_row(
18615                            &visible_table(&table.name),
18616                            &table.name,
18617                            values,
18618                            row.server_version,
18619                        )
18620                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18621                    }
18622                    let processed = u64::from(applied) + row_index as u64 + 1;
18623                    if processed.is_multiple_of(1024) {
18624                        self.progress.update(|p| p.rows_processed = processed);
18625                    }
18626                }
18627                if reconcile_pending {
18628                    self.reconcile_imported_rows(&table, &mut changed_keys)
18629                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18630                }
18631                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18632                    self.apply_outbox_ops(
18633                        self.outbox
18634                            .iter()
18635                            .flat_map(|commit| &commit.ops)
18636                            .filter(|op| op.table == table.name),
18637                    )
18638                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18639                    self.overlay_dirty.set(false);
18640                } else {
18641                    self.rebuild_overlay_if_dirty()
18642                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18643                }
18644                self.finish_observation("syncular_segment_block", batch)
18645                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
18646            })();
18647            if let Err(error) = outcome {
18648                self.rollback_observation("syncular_segment_block");
18649                self.overlay_dirty.restore(was_dirty.clone());
18650                return Err(error);
18651            }
18652            applied += block.len() as u32;
18653            self.progress
18654                .update(|p| p.rows_processed = u64::from(applied));
18655            std::thread::yield_now();
18656        }
18657        self.progress
18658            .update(|p| p.rows_processed = u64::from(applied));
18659        Ok(applied)
18660    }
18661
18662    /// §5.3 sqlite-image application: validate the in-file metadata
18663    /// against the descriptor, validate column names/order against the
18664    /// generated schema, run the §5.6 first-page clear when fresh, then
18665    /// replace-or-upsert every image row with its `_syncular_version`.
18666    /// Mechanics: the image lands in a temp file read through a second
18667    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
18668    /// Each image chunk commits its writes and revision before readers continue.
18669    fn apply_sqlite_segment(
18670        &mut self,
18671        sub_index: usize,
18672        bytes: &[u8],
18673        first_fresh_page: bool,
18674        row_count: i64,
18675        as_of_commit_seq: i64,
18676        scope_digest: &str,
18677    ) -> Result<u32, SectionError> {
18678        let invalid = |detail: &str| {
18679            SectionError::Abort(
18680                "sync.invalid_request".to_owned(),
18681                format!("sqlite segment rejected: {detail} (§5.3)"),
18682            )
18683        };
18684        let (sub_table, effective) = {
18685            let sub = &self.subs[sub_index];
18686            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18687        };
18688        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
18689            SectionError::Abort(
18690                "sync.schema_mismatch".to_owned(),
18691                format!("subscription table {sub_table:?} is not in the client schema"),
18692            )
18693        })?;
18694
18695        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
18696        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
18697        let img = match rusqlite::Connection::open_with_flags(
18698            &path,
18699            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
18700        ) {
18701            Ok(conn) => conn,
18702            Err(_) => {
18703                let _ = std::fs::remove_file(&path);
18704                return Err(invalid("bytes do not open as a SQLite database"));
18705            }
18706        };
18707        let outcome = self.apply_sqlite_image(
18708            &img,
18709            &table,
18710            first_fresh_page,
18711            &effective,
18712            row_count,
18713            as_of_commit_seq,
18714            scope_digest,
18715        );
18716        drop(img);
18717        let _ = std::fs::remove_file(&path);
18718        outcome
18719    }
18720
18721    #[allow(clippy::too_many_arguments)]
18722    fn apply_sqlite_image(
18723        &mut self,
18724        img: &rusqlite::Connection,
18725        table: &crate::schema::TableSchema,
18726        first_fresh_page: bool,
18727        effective: &[(String, Vec<String>)],
18728        row_count: i64,
18729        as_of_commit_seq: i64,
18730        scope_digest: &str,
18731    ) -> Result<u32, SectionError> {
18732        let invalid = |detail: String| {
18733            SectionError::Abort(
18734                "sync.invalid_request".to_owned(),
18735                format!("sqlite segment rejected: {detail} (§5.3)"),
18736            )
18737        };
18738
18739        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
18740        //    one row).
18741        type MetaRow = (i64, String, i64, i64, String, i64, i64);
18742        let meta: MetaRow = img
18743            .query_row(
18744                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
18745                        \"scopeDigest\", \"rowCount\",
18746                        (SELECT count(*) FROM _syncular_segment)
18747                 FROM _syncular_segment",
18748                [],
18749                |row| {
18750                    Ok((
18751                        row.get(0)?,
18752                        row.get(1)?,
18753                        row.get(2)?,
18754                        row.get(3)?,
18755                        row.get(4)?,
18756                        row.get(5)?,
18757                        row.get(6)?,
18758                    ))
18759                },
18760            )
18761            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
18762        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
18763        if meta_count != 1 {
18764            return Err(invalid(format!(
18765                "_syncular_segment must contain exactly one row, found {meta_count}"
18766            )));
18767        }
18768        if format != 1 {
18769            return Err(invalid(format!("format {format}")));
18770        }
18771        if meta_table != table.name {
18772            return Err(invalid(format!("image table {meta_table:?}")));
18773        }
18774        if schema_version != i64::from(self.schema.version) {
18775            return Err(invalid(format!("schemaVersion {schema_version}")));
18776        }
18777        if pin != as_of_commit_seq {
18778            return Err(invalid(format!("asOfCommitSeq {pin}")));
18779        }
18780        if digest != scope_digest {
18781            return Err(invalid("scopeDigest mismatch".to_owned()));
18782        }
18783        if meta_rows != row_count {
18784            return Err(invalid(format!("rowCount {meta_rows}")));
18785        }
18786
18787        // 2. Column names and order vs the generated schema (§5.3 rule 3).
18788        let mut names: Vec<String> = Vec::new();
18789        let mut primary_keys: Vec<String> = Vec::new();
18790        {
18791            let mut stmt = img
18792                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
18793                .map_err(|_| invalid("image data table missing".to_owned()))?;
18794            let mut rows = stmt
18795                .query([])
18796                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
18797            while let Some(row) = rows
18798                .next()
18799                .map_err(|_| invalid("image data table unreadable".to_owned()))?
18800            {
18801                if row
18802                    .get::<_, i64>(5)
18803                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
18804                    > 0
18805                {
18806                    primary_keys.push(row.get(1).map_err(|error| {
18807                        invalid(Self::sqlite_failure(&self.storage_failure, error))
18808                    })?);
18809                }
18810                names.push(
18811                    row.get::<_, String>(1)
18812                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
18813                );
18814            }
18815        }
18816        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
18817        expected.push("_syncular_version");
18818        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
18819            return Err(SectionError::Abort(
18820                "sync.schema_mismatch".to_owned(),
18821                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
18822            ));
18823        }
18824
18825        if primary_keys != vec![table.primary_key.clone()] {
18826            return Err(invalid(
18827                "image primary key does not match generated schema".into(),
18828            ));
18829        }
18830        let actual_count: i64 = img
18831            .query_row(
18832                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
18833                [],
18834                |row| row.get(0),
18835            )
18836            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18837        if actual_count != row_count {
18838            return Err(invalid("image row count does not match descriptor".into()));
18839        }
18840        let insert = self.insert_row_sql(&base_table(&table.name), table);
18841        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
18842        let column_list = names
18843            .iter()
18844            .map(|n| quote_ident(n))
18845            .collect::<Vec<_>>()
18846            .join(", ");
18847        let mut stmt = img
18848            .prepare(&format!(
18849                "SELECT {column_list} FROM {} ORDER BY {}",
18850                quote_ident(&table.name),
18851                quote_ident(&table.primary_key)
18852            ))
18853            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18854        let mut rows = stmt
18855            .query([])
18856            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18857        let mut applied = 0u32;
18858        loop {
18859            let clear = applied == 0 && first_fresh_page;
18860            let was_dirty = self.overlay_dirty.snapshot();
18861            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18862                && self
18863                    .outbox
18864                    .iter()
18865                    .flat_map(|commit| &commit.ops)
18866                    .any(|op| op.table == table.name);
18867            let mirror_visible = !reconcile_pending
18868                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18869            // A chunk holds up to 1,024 rows (§5.3).
18870            self.coalesce_unit(1024)
18871                .and_then(|()| self.begin_observation("syncular_image_chunk"))
18872                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18873            let result = (|| {
18874                self.rebuild_overlay_if_dirty()
18875                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18876                let mut changed_keys = Vec::new();
18877                let mut batch = ChangeAccumulator::default();
18878                if clear {
18879                    if self.scoped_rows_exist(&table.name, effective) {
18880                        batch.table(&table.name);
18881                    }
18882                    self.purge_scope_rows(&table.name, effective)
18883                        .map_err(|()| SectionError::FailClosed)?;
18884                }
18885                let mut processed = 0u32;
18886                {
18887                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
18888                        invalid(Self::sqlite_failure(&self.storage_failure, error))
18889                    })?;
18890                    let mut mirror = if mirror_visible {
18891                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
18892                            invalid(Self::sqlite_failure(&self.storage_failure, error))
18893                        })?)
18894                    } else {
18895                        None
18896                    };
18897                    for _ in 0..1024 {
18898                        let Some(row) = rows.next().map_err(|error| {
18899                            invalid(Self::sqlite_failure(&self.storage_failure, error))
18900                        })?
18901                        else {
18902                            break;
18903                        };
18904                        for (i, column) in table.columns.iter().enumerate() {
18905                            let cell = row.get_ref(i).map_err(|error| {
18906                                invalid(Self::sqlite_failure(&self.storage_failure, error))
18907                            })?;
18908                            let param = image_cell_param(column, cell).map_err(invalid)?;
18909                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
18910                                invalid(Self::sqlite_failure(&self.storage_failure, error))
18911                            })?;
18912                            if let Some(mirror) = &mut mirror {
18913                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
18914                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
18915                                })?;
18916                            }
18917                        }
18918                        let version_index = table.columns.len();
18919                        let version: i64 = row.get(version_index).map_err(|error| {
18920                            invalid(Self::sqlite_failure(&self.storage_failure, error))
18921                        })?;
18922                        if version < 1 {
18923                            return Err(invalid("image row version must be positive".into()));
18924                        }
18925                        ins.raw_bind_parameter(version_index + 1, version)
18926                            .map_err(|error| {
18927                                invalid(Self::sqlite_failure(&self.storage_failure, error))
18928                            })?;
18929                        ins.raw_execute().map_err(|error| {
18930                            invalid(Self::sqlite_failure(&self.storage_failure, error))
18931                        })?;
18932                        if let Some(mirror) = &mut mirror {
18933                            mirror
18934                                .raw_bind_parameter(version_index + 1, version)
18935                                .map_err(|error| {
18936                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
18937                                })?;
18938                            mirror.raw_execute().map_err(|error| {
18939                                invalid(Self::sqlite_failure(&self.storage_failure, error))
18940                            })?;
18941                        }
18942                        if reconcile_pending {
18943                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
18944                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
18945                            )?);
18946                        }
18947                        processed += 1;
18948                    }
18949                }
18950                if processed > 0 {
18951                    batch.table(&table.name);
18952                    self.overlay_dirty.table(&table.name);
18953                }
18954                if reconcile_pending {
18955                    self.reconcile_imported_rows(table, &mut changed_keys)
18956                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18957                }
18958                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18959                    self.apply_outbox_ops(
18960                        self.outbox
18961                            .iter()
18962                            .flat_map(|commit| &commit.ops)
18963                            .filter(|op| op.table == table.name),
18964                    )
18965                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18966                    self.overlay_dirty.set(false);
18967                } else {
18968                    self.rebuild_overlay_if_dirty()
18969                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18970                }
18971                self.finish_observation("syncular_image_chunk", batch)
18972                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18973                Ok(processed)
18974            })();
18975            let processed = match result {
18976                Ok(processed) => processed,
18977                Err(error) => {
18978                    self.rollback_observation("syncular_image_chunk");
18979                    self.overlay_dirty.restore(was_dirty.clone());
18980                    return Err(error);
18981                }
18982            };
18983            applied += processed;
18984            self.progress
18985                .update(|p| p.rows_processed = u64::from(applied));
18986            std::thread::yield_now();
18987            if i64::from(applied) == actual_count {
18988                break;
18989            }
18990        }
18991        Ok(applied)
18992    }
18993
18994    /// Restore only imported and pending row identities before FIFO replay. All pending
18995    /// identities participate: a server row can free a unique value for a different edit.
18996    /// The caller owns the chunk transaction and publishes its revision after replay.
18997    fn reconcile_imported_rows(
18998        &self,
18999        table: &TableSchema,
19000        keys: &mut Vec<SqlValue>,
19001    ) -> Result<(), String> {
19002        let visible = visible_table(&table.name);
19003        let base = base_table(&table.name);
19004        let pk = quote_ident(&table.primary_key);
19005        let predicate = row_id_predicate(table);
19006        let mut lookup = self.conn.prepare_cached(&format!(
19007            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
19008        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19009        let mut deleted = HashSet::new();
19010        for op in self
19011            .outbox
19012            .iter()
19013            .flat_map(|commit| &commit.ops)
19014            .filter(|op| op.table == table.name)
19015        {
19016            if op.upsert {
19017                if let Some(values) = &op.values {
19018                    let key = json_to_column_value(
19019                        &table.columns[table.pk_index],
19020                        values.get(&table.primary_key),
19021                    )?;
19022                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
19023                }
19024            } else if deleted.insert(&op.row_id) {
19025                keys.extend(
19026                    lookup
19027                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
19028                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19029                        .collect::<Result<Vec<_>, _>>()
19030                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19031                );
19032            }
19033        }
19034        // Delete the complete affected set before restoring any base value, so
19035        // old optimistic unique values cannot collide with the new server rows.
19036        for chunk in keys.chunks(400) {
19037            let holes = vec!["?"; chunk.len()].join(",");
19038            self.conn
19039                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
19040                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19041                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19042        }
19043        let updates = table
19044            .columns
19045            .iter()
19046            .map(|column| column.name.as_str())
19047            .chain(std::iter::once("_syncular_version"))
19048            .filter(|name| *name != table.primary_key)
19049            .map(|name| {
19050                let name = quote_ident(name);
19051                format!("{name}=excluded.{name}")
19052            })
19053            .collect::<Vec<_>>()
19054            .join(",");
19055        for chunk in keys.chunks(400) {
19056            let holes = vec!["?"; chunk.len()].join(",");
19057            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
19058                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19059                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19060        }
19061        Ok(())
19062    }
19063
19064    // -- application-authorized local purge ---------------------------------------
19065
19066    fn compile_local_data_purge(
19067        &self,
19068        input: &LocalDataPurgeInput,
19069    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
19070        let invalid = |message: String| format!("sync.invalid_request: {message}");
19071        if input.purge_id.is_empty()
19072            || input.purge_id.len() > 128
19073            || !is_local_operation_code_like(&input.purge_id)
19074        {
19075            return Err(invalid(
19076                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
19077            ));
19078        }
19079        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
19080            return Err(invalid(format!(
19081                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
19082            )));
19083        }
19084
19085        let mut deduplicated: BTreeMap<
19086            String,
19087            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
19088        > = BTreeMap::new();
19089        for target in &input.targets {
19090            let table = self.schema.table(&target.table).ok_or_else(|| {
19091                invalid(format!(
19092                    "local purge names unknown table {:?}",
19093                    target.table
19094                ))
19095            })?;
19096            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
19097                return Err(invalid(format!(
19098                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
19099                    target.table
19100                )));
19101            }
19102            let mut selectors = Vec::with_capacity(target.selectors.len());
19103            let mut canonical_selectors = BTreeMap::new();
19104            for (column_name, raw_values) in &target.selectors {
19105                let Some((column_index, column)) = table
19106                    .columns
19107                    .iter()
19108                    .enumerate()
19109                    .find(|(_, column)| column.name == *column_name)
19110                else {
19111                    return Err(invalid(format!(
19112                        "local purge target {:?} names unknown column {:?}",
19113                        target.table, column_name
19114                    )));
19115                };
19116                let encrypted = table
19117                    .encrypted_columns
19118                    .iter()
19119                    .any(|candidate| candidate.index == column_index);
19120                if column.ty != ColumnType::String || encrypted {
19121                    return Err(invalid(format!(
19122                        "local purge selector {:?}.{:?} must be a plaintext string column",
19123                        target.table, column_name
19124                    )));
19125                }
19126                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
19127                    return Err(invalid(format!(
19128                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
19129                        target.table, column_name
19130                    )));
19131                }
19132                let mut values = raw_values.clone();
19133                values.sort();
19134                values.dedup();
19135                if values.iter().any(|value| {
19136                    value.is_empty()
19137                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
19138                        || !is_local_operation_code_like(value)
19139                }) {
19140                    return Err(invalid(format!(
19141                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
19142                    )));
19143                }
19144                selectors.push((column_name.clone(), values.clone()));
19145                canonical_selectors.insert(column_name.clone(), values);
19146            }
19147            let canonical = LocalDataPurgeTarget {
19148                table: target.table.clone(),
19149                selectors: canonical_selectors,
19150            };
19151            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19152            deduplicated.insert(
19153                key,
19154                (
19155                    CompiledLocalDataPurgeTarget {
19156                        table: target.table.clone(),
19157                        selectors,
19158                    },
19159                    canonical,
19160                ),
19161            );
19162        }
19163        let targets = deduplicated
19164            .values()
19165            .map(|(compiled, _)| compiled.clone())
19166            .collect::<Vec<_>>();
19167        let canonical = deduplicated
19168            .values()
19169            .map(|(_, target)| target.clone())
19170            .collect::<Vec<_>>();
19171        let canonical_plan =
19172            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19173        Ok((targets, canonical_plan))
19174    }
19175
19176    fn local_purge_base_row_ids(
19177        &self,
19178        targets: &[CompiledLocalDataPurgeTarget],
19179    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
19180        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
19181        for target in targets {
19182            let table = self
19183                .schema
19184                .table(&target.table)
19185                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
19186            let mut clauses = Vec::with_capacity(target.selectors.len());
19187            let mut params = Vec::new();
19188            for (column, values) in &target.selectors {
19189                clauses.push(format!(
19190                    "{} IN ({})",
19191                    quote_ident(column),
19192                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
19193                ));
19194                params.extend(values.iter().cloned().map(SqlValue::Text));
19195            }
19196            let sql = format!(
19197                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
19198                quote_ident(&table.primary_key),
19199                base_table(&target.table),
19200                clauses.join(" AND ")
19201            );
19202            let mut statement = self
19203                .conn
19204                .prepare(&sql)
19205                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19206            let rows = statement
19207                .query_map(rusqlite::params_from_iter(params), |row| {
19208                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
19209                    render_row_id_json(Some(&value))
19210                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
19211                })
19212                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19213            let ids = by_table.entry(target.table.clone()).or_default();
19214            for row in rows {
19215                ids.insert(
19216                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19217                );
19218            }
19219        }
19220        Ok(by_table)
19221    }
19222
19223    fn local_purge_values_match(
19224        target: &CompiledLocalDataPurgeTarget,
19225        values: &Map<String, Value>,
19226    ) -> bool {
19227        target.selectors.iter().all(|(column, allowed)| {
19228            values
19229                .get(column)
19230                .and_then(Value::as_str)
19231                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
19232        })
19233    }
19234
19235    /// Apply one host-authorized local security purge. The host MUST gate the
19236    /// corresponding subscriptions first; this primitive owns local SQLite
19237    /// cleanup and never grants/revokes server authority by itself.
19238    pub fn purge_local_data(
19239        &mut self,
19240        input: &LocalDataPurgeInput,
19241    ) -> Result<LocalDataPurgeResult, String> {
19242        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
19243        let meta_key = format!("localPurge:{}", input.purge_id);
19244        let applied_plan = self
19245            .conn
19246            .query_row(
19247                "SELECT value FROM _syncular_meta WHERE key = ?1",
19248                rusqlite::params![meta_key],
19249                |row| row.get::<_, String>(0),
19250            )
19251            .optional()
19252            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19253        if let Some(applied_plan) = applied_plan {
19254            if applied_plan != canonical_plan {
19255                return Err(format!(
19256                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
19257                    input.purge_id
19258                ));
19259            }
19260            return Ok(LocalDataPurgeResult {
19261                already_applied: true,
19262                purged_rows: 0,
19263                dropped_commits: 0,
19264            });
19265        }
19266
19267        // RFC 0005 D7: the container file cannot be a purge target (its name is
19268        // not in the running schema), so purge drops it as a fixed step, before
19269        // any mirror row is touched and unconditionally.
19270        let _ = self.drop_previous_version();
19271
19272        let prior_outbox = self.outbox.clone();
19273        let prior_failed = self.failed_commits.clone();
19274        let prior_rejections = self.rejections.clone();
19275        let prior_conflicts = self.conflicts.clone();
19276        let prior_overlay_dirty = self.overlay_dirty.snapshot();
19277        self.begin_observation("syncular_local_purge")?;
19278        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
19279            let row_ids = self.local_purge_base_row_ids(&targets)?;
19280            let retained_dropped = self.drop_failed_commits(|table, values| {
19281                targets.iter().any(|target| {
19282                    target.table == table && Self::local_purge_values_match(target, values)
19283                })
19284            })?;
19285            let doomed = self
19286                .outbox
19287                .iter()
19288                .filter(|commit| {
19289                    commit.ops.iter().any(|operation| {
19290                        let matching_targets = targets
19291                            .iter()
19292                            .filter(|target| target.table == operation.table)
19293                            .collect::<Vec<_>>();
19294                        if matching_targets.is_empty() {
19295                            return false;
19296                        }
19297                        if row_ids
19298                            .get(&operation.table)
19299                            .is_some_and(|ids| ids.contains(&operation.row_id))
19300                        {
19301                            return true;
19302                        }
19303                        operation.values.as_ref().is_some_and(|values| {
19304                            matching_targets
19305                                .iter()
19306                                .any(|target| Self::local_purge_values_match(target, values))
19307                        })
19308                    })
19309                })
19310                .cloned()
19311                .collect::<Vec<_>>();
19312            let doomed_ids = doomed
19313                .iter()
19314                .map(|commit| commit.client_commit_id.clone())
19315                .collect::<BTreeSet<_>>();
19316            let mut batch = ChangeAccumulator::default();
19317            for commit in &retained_dropped {
19318                batch.conflicts = true;
19319                batch.rejections = true;
19320                batch.outcomes = true;
19321                for operation in &commit.ops {
19322                    batch.table(&operation.table);
19323                }
19324            }
19325            let mut rejections = Vec::new();
19326            for commit in &doomed {
19327                for operation in &commit.ops {
19328                    batch.table(&operation.table);
19329                }
19330                let results = commit
19331                    .ops
19332                    .iter()
19333                    .enumerate()
19334                    .map(|(op_index, operation)| {
19335                        let rejection = RejectionRecord {
19336                            client_commit_id: commit.client_commit_id.clone(),
19337                            op_index: op_index as i32,
19338                            code: "client.local_data_purged".to_owned(),
19339                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
19340                            retryable: false,
19341                            details: None,
19342                            operation: Some(CommitOperation::from(operation)),
19343                        };
19344                        rejections.push(rejection.clone());
19345                        CommitOperationOutcome::Error { rejection }
19346                    })
19347                    .collect::<Vec<_>>();
19348                self.persist_commit_outcome(
19349                    &commit.client_commit_id,
19350                    CommitOutcomeStatus::Rejected,
19351                    &results,
19352                    Some(&commit.ops),
19353                )?;
19354                self.delete_outbox_persisted(&commit.client_commit_id)?;
19355            }
19356            if !doomed.is_empty() {
19357                self.outbox
19358                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
19359                self.rejections.extend(rejections);
19360                self.prune_commit_outcomes()?;
19361                batch.status = true;
19362                batch.rejections = true;
19363                batch.outcomes = true;
19364            }
19365
19366            let mut purged_rows = 0usize;
19367            for (table, ids) in &row_ids {
19368                if ids.is_empty() {
19369                    continue;
19370                }
19371                batch.table(table);
19372                for row_id in ids {
19373                    self.delete_base_row(table, row_id)?;
19374                    purged_rows += 1;
19375                }
19376            }
19377            self.rebuild_overlay_if_dirty()?;
19378            self.delete_unreferenced_cached_blobs()?;
19379            self.conn
19380                .execute(
19381                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19382                    rusqlite::params![meta_key, canonical_plan],
19383                )
19384                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19385            Ok((
19386                batch,
19387                LocalDataPurgeResult {
19388                    already_applied: false,
19389                    purged_rows,
19390                    dropped_commits: doomed.len(),
19391                },
19392            ))
19393        })();
19394        let (batch, result) = match applied {
19395            Ok(value) => value,
19396            Err(error) => {
19397                self.rollback_observation("syncular_local_purge");
19398                self.outbox = prior_outbox;
19399                self.failed_commits = prior_failed;
19400                self.rejections = prior_rejections;
19401                self.conflicts = prior_conflicts;
19402                self.overlay_dirty.restore(prior_overlay_dirty.clone());
19403                return Err(error);
19404            }
19405        };
19406        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
19407            self.rollback_observation("syncular_local_purge");
19408            self.outbox = prior_outbox;
19409            self.failed_commits = prior_failed;
19410            self.rejections = prior_rejections;
19411            self.conflicts = prior_conflicts;
19412            self.overlay_dirty.restore(prior_overlay_dirty.clone());
19413            return Err(error);
19414        }
19415        self.cancel_sync_round();
19416        Ok(result)
19417    }
19418
19419    /// Application-authorized recovery of the replicated projection. Unlike a
19420    /// local security purge, this retains the entire outbox, device identity,
19421    /// lease, outcomes, subscription registrations, and protected bookkeeping.
19422    /// The projection reset, subscription rewind, optimistic replay, and
19423    /// idempotency marker share one savepoint for interruption safety.
19424    pub fn rebootstrap_local_data(
19425        &mut self,
19426        input: &LocalDataRebootstrapInput,
19427    ) -> Result<LocalDataRebootstrapResult, String> {
19428        if input.rebootstrap_id.is_empty()
19429            || input.rebootstrap_id.len() > 128
19430            || !is_local_operation_code_like(&input.rebootstrap_id)
19431        {
19432            return Err(
19433                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
19434                    .to_owned(),
19435            );
19436        }
19437        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
19438        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
19439            let (retained_commits, reset_subscriptions) =
19440                decode_local_rebootstrap_receipt(&persisted)?;
19441            return Ok(LocalDataRebootstrapResult {
19442                already_applied: true,
19443                retained_commits,
19444                reset_subscriptions,
19445            });
19446        }
19447        if self.stopped || self.schema_floor.is_some() {
19448            return Err(
19449                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
19450                    .to_owned(),
19451            );
19452        }
19453
19454        let retained_commits = self.outbox.len();
19455        let reset_subscriptions = self.subs.len();
19456        let prior_subs = self.subs.clone();
19457        let prior_upgrading = self.upgrading;
19458        let prior_stopped = self.stopped;
19459        let prior_schema_floor = self.schema_floor.clone();
19460        let prior_overlay_dirty = self.overlay_dirty.snapshot();
19461        let prior_sync_needed = self.sync_needed;
19462        let prior_sync_intents = self.sync_intent_queue.clone();
19463        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
19464
19465        let prior_active_round = self.active_round;
19466        self.begin_observation("syncular_local_rebootstrap")?;
19467        let mut batch = ChangeAccumulator::default();
19468        let applied = (|| -> Result<(), String> {
19469            self.run_schema_reset_observed(&mut batch, false)?;
19470            self.conn
19471                .execute(
19472                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19473                    rusqlite::params![meta_key, receipt],
19474                )
19475                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19476            self.sync_needed = true;
19477            self.sync_intent_queue.push_back(SyncIntent::Interactive);
19478            batch.status = true;
19479            Ok(())
19480        })();
19481        if let Err(error) = applied {
19482            self.rollback_observation("syncular_local_rebootstrap");
19483            self.active_round = prior_active_round;
19484            self.subs = prior_subs;
19485            self.upgrading = prior_upgrading;
19486            self.stopped = prior_stopped;
19487            self.schema_floor = prior_schema_floor;
19488            self.overlay_dirty.restore(prior_overlay_dirty.clone());
19489            self.sync_needed = prior_sync_needed;
19490            self.sync_intent_queue = prior_sync_intents;
19491            return Err(error);
19492        }
19493        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
19494            self.rollback_observation("syncular_local_rebootstrap");
19495            self.active_round = prior_active_round;
19496            self.subs = prior_subs;
19497            self.upgrading = prior_upgrading;
19498            self.stopped = prior_stopped;
19499            self.schema_floor = prior_schema_floor;
19500            self.overlay_dirty.restore(prior_overlay_dirty.clone());
19501            self.sync_needed = prior_sync_needed;
19502            self.sync_intent_queue = prior_sync_intents;
19503            return Err(error);
19504        }
19505
19506        Ok(LocalDataRebootstrapResult {
19507            already_applied: false,
19508            retained_commits,
19509            reset_subscriptions,
19510        })
19511    }
19512
19513    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
19514
19515    /// Delete base rows matching the effective scopes; `Err(())` = no local
19516    /// scope-column mapping for a key (the fail-closed case).
19517    fn purge_scope_rows(
19518        &mut self,
19519        table_name: &str,
19520        effective: &[(String, Vec<String>)],
19521    ) -> Result<(), ()> {
19522        if effective.is_empty() {
19523            return Ok(());
19524        }
19525        let table = self.schema.table(table_name).ok_or(())?.clone();
19526        let mut clauses = Vec::new();
19527        let mut params: Vec<SqlValue> = Vec::new();
19528        for (variable, values) in effective {
19529            let column = table.scope_column(variable).ok_or(())?;
19530            let placeholders: Vec<String> = values
19531                .iter()
19532                .map(|v| {
19533                    params.push(SqlValue::Text(v.clone()));
19534                    "?".to_owned()
19535                })
19536                .collect();
19537            clauses.push(format!(
19538                "{} IN ({})",
19539                quote_ident(column),
19540                placeholders.join(", ")
19541            ));
19542        }
19543        let sql = format!(
19544            "DELETE FROM {} WHERE {}",
19545            base_table(table_name),
19546            clauses.join(" AND ")
19547        );
19548        self.overlay_dirty.table(table_name);
19549        self.conn
19550            .execute(&sql, rusqlite::params_from_iter(&params))
19551            .map_err(|_| ())?;
19552        self.conn
19553            .execute(
19554                &format!(
19555                    "DELETE FROM {} WHERE {}",
19556                    visible_table(table_name),
19557                    clauses.join(" AND ")
19558                ),
19559                rusqlite::params_from_iter(&params),
19560            )
19561            .map_err(|_| ())?;
19562        Ok(())
19563    }
19564
19565    /// §3.3: drop pending commits whose upserts provably land in the
19566    /// revoked effective scopes — whole-commit, never per-operation.
19567    fn drop_doomed_outbox(
19568        &mut self,
19569        table_name: &str,
19570        effective: &[(String, Vec<String>)],
19571    ) -> Result<bool, String> {
19572        if effective.is_empty() {
19573            return Ok(false);
19574        }
19575        let Some(table) = self.schema.table(table_name).cloned() else {
19576            return Ok(false);
19577        };
19578        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
19579        for (variable, values) in effective {
19580            match table.scope_column(variable) {
19581                Some(column) => mappings.push((column, values)),
19582                None => return Ok(false), // not provable without a mapping
19583            }
19584        }
19585        let doomed: Vec<OutboxCommit> = self
19586            .outbox
19587            .iter()
19588            .filter(|commit| {
19589                commit.ops.iter().any(|op| {
19590                    op.upsert
19591                        && op.table == table_name
19592                        && op.values.as_ref().is_some_and(|values| {
19593                            mappings
19594                                .iter()
19595                                .all(|(column, allowed)| match values.get(*column) {
19596                                    Some(Value::String(s)) => allowed.contains(s),
19597                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
19598                                    Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
19599                                    _ => false,
19600                                })
19601                        })
19602                })
19603            })
19604            .cloned()
19605            .collect();
19606        if doomed.is_empty() {
19607            return Ok(false);
19608        }
19609        let mut rejections = Vec::new();
19610        for commit in &doomed {
19611            let results = commit
19612                .ops
19613                .iter()
19614                .enumerate()
19615                .map(|(op_index, operation)| {
19616                    let rejection = RejectionRecord {
19617                        client_commit_id: commit.client_commit_id.clone(),
19618                        op_index: op_index as i32,
19619                        code: "sync.scope_revoked".to_owned(),
19620                        message: "the commit was dropped because its effective scope was revoked"
19621                            .to_owned(),
19622                        retryable: false,
19623                        details: None,
19624                        operation: Some(CommitOperation::from(operation)),
19625                    };
19626                    rejections.push(rejection.clone());
19627                    CommitOperationOutcome::Error { rejection }
19628                })
19629                .collect::<Vec<_>>();
19630            self.persist_commit_outcome(
19631                &commit.client_commit_id,
19632                CommitOutcomeStatus::Rejected,
19633                &results,
19634                Some(&commit.ops),
19635            )?;
19636            self.delete_outbox_persisted(&commit.client_commit_id)?;
19637        }
19638        self.prune_commit_outcomes()?;
19639        let doomed_ids = doomed
19640            .iter()
19641            .map(|commit| commit.client_commit_id.as_str())
19642            .collect::<BTreeSet<_>>();
19643        self.outbox
19644            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
19645        self.rejections.extend(rejections);
19646        Ok(true)
19647    }
19648
19649    // -- blobs (§5.9) ----------------------------------------------------------------
19650
19651    /// §5.9.7: hash bytes into the content address, cache them, queue the
19652    /// upload (flushed before the next push, B4). Returns the canonical
19653    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
19654    /// column value.
19655    pub fn upload_blob(
19656        &mut self,
19657        bytes: &[u8],
19658        media_type: Option<String>,
19659        name: Option<String>,
19660    ) -> Result<Value, String> {
19661        let blob_id = blob_id_for(bytes);
19662        let now = self.clock_now_ms();
19663        let tx = self
19664            .conn
19665            .transaction()
19666            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19667        tx.execute(
19668            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
19669             ON CONFLICT(blob_id) DO NOTHING",
19670            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
19671        )
19672        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19673        tx.execute(
19674            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
19675            rusqlite::params![blob_id, media_type, now],
19676        )
19677        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19678        tx.commit()
19679            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19680        self.enforce_blob_cache_cap();
19681        let mut obj = Map::new();
19682        obj.insert("blobId".to_owned(), Value::from(blob_id));
19683        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
19684        if let Some(mt) = media_type {
19685            obj.insert("mediaType".to_owned(), Value::from(mt));
19686        }
19687        if let Some(n) = name {
19688            obj.insert("name".to_owned(), Value::from(n));
19689        }
19690        Ok(Value::Object(obj))
19691    }
19692
19693    /// §5.9.7: resolve an owned blob body without driver encoding. A
19694    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
19695    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
19696    pub fn fetch_blob_bytes(
19697        &mut self,
19698        transport: &mut dyn Transport,
19699        blob_id_or_ref: &str,
19700    ) -> Result<FetchedBlob, (String, String)> {
19701        let simple = |m: String| ("client.failed".to_owned(), m);
19702        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
19703            blob_id_or_ref.to_owned()
19704        } else {
19705            let value: Value = serde_json::from_str(blob_id_or_ref)
19706                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
19707            value
19708                .get("blobId")
19709                .and_then(Value::as_str)
19710                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
19711                .to_owned()
19712        };
19713        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
19714            return Ok(cached);
19715        }
19716        if !self.transport_enabled {
19717            return Err(("sync.offline".into(), "transport gate is closed".into()));
19718        }
19719        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
19720        // blob.not_found) verbatim so the harness can assert on it. The
19721        // authorized endpoint serves bytes inline OR (always-issue, presign
19722        // configured) a signed url the client fetches directly — no host auth,
19723        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
19724        #[cfg(feature = "bench-internals")]
19725        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
19726        let bytes = match transport
19727            .blob_download(&blob_id)
19728            .map_err(|e| (e.code, e.message))?
19729        {
19730            BlobDownload::Bytes(bytes) => bytes,
19731            BlobDownload::Url {
19732                url,
19733                url_expires_at_ms,
19734            } => {
19735                // §5.9.5: MUST NOT start a fetch at/past expiry.
19736                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
19737                    return Err((
19738                        "sync.segment_expired".to_owned(),
19739                        format!(
19740                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
19741                        ),
19742                    ));
19743                }
19744                transport
19745                    .fetch_blob_url(&url)
19746                    .map_err(|e| (e.code, e.message))?
19747            }
19748        };
19749        #[cfg(feature = "bench-internals")]
19750        drop(download_phase);
19751        #[cfg(feature = "bench-internals")]
19752        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
19753        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
19754        if blob_id_for(&bytes) != blob_id {
19755            return Err(simple(format!(
19756                "blob content address mismatch for {blob_id}"
19757            )));
19758        }
19759        #[cfg(feature = "bench-internals")]
19760        drop(validate_phase);
19761        #[cfg(feature = "bench-internals")]
19762        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
19763        let now = self.clock_now_ms();
19764        self.conn
19765            .execute(
19766                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
19767                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
19768            )
19769            .map_err(|e| simple(e.to_string()))?;
19770        self.conn
19771            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
19772            .map_err(|e| simple(e.to_string()))?;
19773        self.enforce_blob_cache_cap();
19774        #[cfg(feature = "bench-internals")]
19775        drop(insert_phase);
19776        self.get_cached_blob_bytes(&blob_id)
19777            .map_err(simple)?
19778            .ok_or_else(|| simple("blob cache write failed".to_owned()))
19779    }
19780
19781    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
19782        #[cfg(feature = "bench-internals")]
19783        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
19784        let mut stmt = self
19785            .conn
19786            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
19787            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19788        let mut rows = stmt
19789            .query(rusqlite::params![blob_id])
19790            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19791        if let Some(row) = rows
19792            .next()
19793            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19794        {
19795            let bytes: Vec<u8> = row
19796                .get(0)
19797                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19798            let byte_length: i64 = row
19799                .get(1)
19800                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19801            let media_type: Option<String> = row
19802                .get(2)
19803                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19804            return Ok(Some(FetchedBlob {
19805                blob_id: blob_id.to_owned(),
19806                byte_length,
19807                bytes,
19808                media_type,
19809            }));
19810        }
19811        Ok(None)
19812    }
19813
19814    /// §5.9.7 B4: upload every queued blob before push.
19815    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
19816        let pending: Vec<(String, Option<String>)> = {
19817            let mut stmt = self
19818                .conn
19819                .prepare(
19820                    "SELECT blob_id, media_type FROM (
19821                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
19822                      UNION ALL
19823                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
19824                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
19825                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
19826                    ) ORDER BY created_at_ms, blob_id",
19827                )
19828                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19829            let rows = stmt
19830                .query_map([], |row| {
19831                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
19832                })
19833                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19834            rows.collect::<Result<_, _>>()
19835                .map_err(|error| match error {
19836                    rusqlite::Error::InvalidColumnType(..)
19837                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
19838                        "sync.local_corrupt",
19839                        "Pending blob upload metadata is invalid",
19840                    ),
19841                    _ => TransportError::new("client.failed", error.to_string()),
19842                })?
19843        };
19844        let mut uploads = Vec::new();
19845        for (blob_id, media_type) in pending {
19846            let bytes = (|| {
19847                let stored: Option<(SqlValue, SqlValue)> = self
19848                    .conn
19849                    .query_row(
19850                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
19851                        rusqlite::params![blob_id],
19852                        |row| Ok((row.get(0)?, row.get(1)?)),
19853                    )
19854                    .optional()
19855                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19856                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
19857                    return Err(TransportError::new(
19858                        "sync.local_corrupt",
19859                        "Pending blob upload body is missing or corrupt",
19860                    ));
19861                };
19862                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
19863                    return Err(TransportError::new(
19864                        "sync.local_corrupt",
19865                        "Pending blob upload body is missing or corrupt",
19866                    ));
19867                }
19868                Ok(bytes)
19869            })();
19870            let failed = bytes.is_err();
19871            uploads.push(crate::round::PendingUpload {
19872                id: blob_id,
19873                bytes,
19874                media_type,
19875            });
19876            if failed {
19877                break;
19878            }
19879        }
19880        Ok(uploads)
19881    }
19882
19883    #[cfg(test)]
19884    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
19885        for upload in self.pending_blob_uploads()? {
19886            self.upload_one(
19887                transport,
19888                &upload.id,
19889                &upload.bytes?,
19890                upload.media_type.as_deref(),
19891            )?;
19892            self.conn
19893                .execute(
19894                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
19895                    rusqlite::params![upload.id],
19896                )
19897                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19898        }
19899        Ok(())
19900    }
19901
19902    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
19903    /// grant when the transport supports it, else streaming through the direct
19904    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
19905    /// PUTs direct with no host auth; on a grant PUT failure the client streams
19906    /// through the direct endpoint — a *different, host-authenticated
19907    /// capability*, not a fall-through of the grant's authority.
19908    #[cfg(test)]
19909    fn upload_one(
19910        &self,
19911        transport: &mut dyn Transport,
19912        blob_id: &str,
19913        bytes: &[u8],
19914        media_type: Option<&str>,
19915    ) -> Result<(), TransportError> {
19916        crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
19917    }
19918
19919    fn visible_blob_ids_sql(&self) -> String {
19920        let mut selects = Vec::new();
19921        for table in &self.schema.tables {
19922            for column in table
19923                .columns
19924                .iter()
19925                .filter(|column| column.ty == ColumnType::BlobRef)
19926            {
19927                let identifier = quote_ident(&column.name);
19928                selects.push(format!(
19929                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
19930                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
19931                     AND json_type({identifier}, '$.blobId') = 'text'",
19932                    quote_ident(&table.name),
19933                ));
19934            }
19935        }
19936        if selects.is_empty() {
19937            "SELECT NULL AS blob_id WHERE 0".to_owned()
19938        } else {
19939            selects.join(" UNION ")
19940        }
19941    }
19942
19943    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
19944    /// staged uploads pin bodies directly; no derived refcount is stored.
19945    fn enforce_blob_cache_cap(&self) {
19946        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
19947            return;
19948        };
19949        let mut total: i64 = self
19950            .conn
19951            .query_row(
19952                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
19953                [],
19954                |row| row.get(0),
19955            )
19956            .unwrap_or(0);
19957        if total <= max_bytes {
19958            return;
19959        }
19960        let query = format!(
19961            "SELECT blob_id, byte_length FROM _syncular_blobs
19962             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19963               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19964               AND blob_id NOT IN ({})
19965             ORDER BY created_at_ms ASC, blob_id ASC",
19966            self.visible_blob_ids_sql(),
19967        );
19968        let candidates: Vec<(String, i64)> = {
19969            let Ok(mut stmt) = self.conn.prepare(&query) else {
19970                return;
19971            };
19972            let Ok(rows) = stmt.query_map([], |row| {
19973                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
19974            }) else {
19975                return;
19976            };
19977            rows.filter_map(Result::ok).collect()
19978        };
19979        for (blob_id, byte_length) in candidates {
19980            if total <= max_bytes {
19981                break;
19982            }
19983            let _ = self.conn.execute(
19984                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
19985                rusqlite::params![blob_id],
19986            );
19987            total -= byte_length;
19988        }
19989    }
19990
19991    fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
19992        #[cfg(feature = "bench-internals")]
19993        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
19994        if !self.schema_has_blobs() {
19995            return Ok(());
19996        }
19997        self.rebuild_overlay_if_dirty()?;
19998        self.conn
19999            .execute(
20000                &format!(
20001                    "DELETE FROM _syncular_blobs
20002                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20003                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20004                   AND blob_id NOT IN ({})",
20005                    self.visible_blob_ids_sql(),
20006                ),
20007                [],
20008            )
20009            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20010        Ok(())
20011    }
20012
20013    // -- local row storage ----------------------------------------------------------
20014
20015    /// The cached per-table primary-key upsert SQL for `full_table` (see
20016    /// the `insert_sql` field: built once, reused per row).
20017    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
20018        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
20019            return sql.clone();
20020        }
20021        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
20022        columns.push(quote_ident("_syncular_version"));
20023        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
20024        let primary_key = quote_ident(&table.primary_key);
20025        let updates = columns
20026            .iter()
20027            .filter(|column| **column != primary_key)
20028            .map(|column| format!("{column}=excluded.{column}"))
20029            .collect::<Vec<_>>()
20030            .join(", ");
20031        let sql = format!(
20032            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
20033            columns.join(", "),
20034            placeholders.join(", ")
20035        );
20036        self.insert_sql
20037            .borrow_mut()
20038            .insert(full_table.to_owned(), sql.clone());
20039        sql
20040    }
20041
20042    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
20043        self.overlay_dirty.table(table_name);
20044        self.write_row(&base_table(table_name), table_name, row, version)
20045    }
20046
20047    fn write_row(
20048        &self,
20049        full_table: &str,
20050        table_name: &str,
20051        row: &Row,
20052        version: i64,
20053    ) -> Result<(), String> {
20054        #[cfg(feature = "bench-internals")]
20055        let _phase = self.benchmark_phases.start(Phase::RowWrite);
20056        let table = self
20057            .schema
20058            .table(table_name)
20059            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20060        let sql = self.insert_row_sql(full_table, table);
20061        let mut stmt = self
20062            .conn
20063            .prepare_cached(&sql)
20064            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20065        let params = row
20066            .iter()
20067            .map(RowParam::Cell)
20068            .chain(std::iter::once(RowParam::Version(version)));
20069        stmt.execute(rusqlite::params_from_iter(params))
20070            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20071        Ok(())
20072    }
20073
20074    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
20075        let table = self
20076            .schema
20077            .table(table_name)
20078            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20079        self.overlay_dirty.table(table_name);
20080        let sql = format!(
20081            "DELETE FROM {} WHERE {}",
20082            base_table(table_name),
20083            row_id_predicate(table)
20084        );
20085        let mut stmt = self
20086            .conn
20087            .prepare_cached(&sql)
20088            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20089        stmt.execute(rusqlite::params![row_id])
20090            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20091        Ok(())
20092    }
20093
20094    fn has_acknowledged_rows(&self) -> bool {
20095        self.conn
20096            .query_row(
20097                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
20098                [],
20099                |row| row.get(0),
20100            )
20101            .expect("read acknowledged intent")
20102    }
20103
20104    fn acknowledged_operations(
20105        &self,
20106        rows: Option<&HashSet<(&str, &str)>>,
20107        tables: Option<&BTreeSet<&str>>,
20108    ) -> Result<Vec<OutboxOp>, String> {
20109        let keys = rows.map(|rows| {
20110            serde_json::to_string(
20111                &rows
20112                    .iter()
20113                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
20114                    .collect::<Vec<_>>(),
20115            )
20116            .expect("row keys")
20117        });
20118        let mut statement = self
20119            .conn
20120            .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")
20121            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20122        let rows = statement
20123            .query_map(
20124                rusqlite::params![
20125                    keys,
20126                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
20127                ],
20128                |row| {
20129                    #[cfg(test)]
20130                    self.acknowledged_replay_count
20131                        .set(self.acknowledged_replay_count.get() + 1);
20132                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
20133                },
20134            )
20135            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20136        rows.map(|row| {
20137            let (op, intent) =
20138                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20139            let op: CommitOperation =
20140                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
20141            let table = self
20142                .schema
20143                .table(&op.table)
20144                .ok_or_else(|| "sync.unknown_table".to_owned())?;
20145            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
20146                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
20147            } else {
20148                op.values
20149            };
20150            Ok(OutboxOp {
20151                table: op.table,
20152                row_id: op.row_id,
20153                upsert: op.op == "upsert",
20154                base_version: op.base_version,
20155                values,
20156            })
20157        })
20158        .collect()
20159    }
20160
20161    fn drop_acknowledged_rows(
20162        &self,
20163        matches: impl Fn(&str, &Map<String, Value>) -> bool,
20164    ) -> Result<Vec<OutboxCommit>, String> {
20165        let mut statement = self
20166            .conn
20167            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
20168            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20169        let rows = statement
20170            .query_map([], |row| {
20171                Ok((
20172                    row.get::<_, String>(0)?,
20173                    row.get::<_, String>(1)?,
20174                    row.get::<_, String>(2)?,
20175                    row.get::<_, String>(3)?,
20176                ))
20177            })
20178            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20179        let mut doomed = HashSet::new();
20180        for row in rows {
20181            let (id, table, intent, row_id) =
20182                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20183            let values =
20184                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
20185            let base_matches = self
20186                .schema
20187                .table(&table)
20188                .map(|schema| self.stored_row(schema, &row_id, true))
20189                .transpose()?
20190                .flatten()
20191                .is_some_and(|(base, _)| {
20192                    let schema = self.schema.table(&table).expect("acknowledged table");
20193                    let values = schema
20194                        .columns
20195                        .iter()
20196                        .zip(base.iter())
20197                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
20198                        .collect();
20199                    matches(&table, &values)
20200                });
20201            if matches(&table, &values) || base_matches {
20202                doomed.insert(id);
20203            }
20204        }
20205        let mut dropped = Vec::new();
20206        for id in doomed {
20207            let mut statement = self
20208                .conn
20209                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
20210                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20211            let operations = statement
20212                .query_map([&id], |row| row.get::<_, String>(0))
20213                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20214            let mut ops = Vec::new();
20215            for operation in operations {
20216                let json = operation
20217                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20218                let op: CommitOperation =
20219                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
20220                self.overlay_dirty.table(&op.table);
20221                ops.push(OutboxOp {
20222                    table: op.table,
20223                    row_id: op.row_id,
20224                    upsert: op.op == "upsert",
20225                    base_version: op.base_version,
20226                    values: op.values,
20227                });
20228            }
20229            dropped.push(OutboxCommit {
20230                client_commit_id: id.clone(),
20231                ops,
20232            });
20233            self.conn
20234                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
20235                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20236        }
20237        Ok(dropped)
20238    }
20239
20240    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
20241    /// the outbox changed since the last rebuild (the no-op sync round).
20242    fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
20243        if self.overlay_dirty.get() {
20244            self.rebuild_overlay()?;
20245        }
20246        Ok(())
20247    }
20248
20249    /// §7.1: local reads see outbox state applied optimistically — rebuild
20250    /// every visible table as (base server state) + (pending outbox replay
20251    /// on top). Optimistic rows carry version `-1`.
20252    fn rebuild_overlay(&mut self) -> Result<(), String> {
20253        #[cfg(feature = "bench-internals")]
20254        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
20255        #[cfg(test)]
20256        self.overlay_rebuild_count
20257            .set(self.overlay_rebuild_count.get() + 1);
20258        let dirty = self.overlay_dirty.snapshot();
20259        let tables: Vec<_> = self
20260            .schema
20261            .tables
20262            .iter()
20263            .filter(|table| {
20264                dirty
20265                    .as_ref()
20266                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
20267            })
20268            .cloned()
20269            .collect();
20270        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
20271        self.begin_observation("syncular_overlay")?;
20272        let rebuilt = (|| {
20273            for table in &tables {
20274                for index in &table.fts_indexes {
20275                    self.drop_fts_triggers(index)?;
20276                }
20277                let visible = visible_table(&table.name);
20278                let base = base_table(&table.name);
20279                self.conn
20280                    .execute_batch(&format!(
20281                        "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
20282                    ))
20283                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20284            }
20285            let acknowledged = self.acknowledged_operations(None, Some(&names))?;
20286            self.apply_outbox_ops(
20287                acknowledged
20288                    .iter()
20289                    .filter(|op| names.contains(op.table.as_str())),
20290            )?;
20291            for (commit, initial) in &self.failed_commits {
20292                for operation in &commit.ops {
20293                    if !names.contains(operation.table.as_str()) || !operation.upsert {
20294                        continue;
20295                    }
20296                    if let Some(table) = self.schema.table(&operation.table) {
20297                        if self.visible_row(table, &operation.row_id)?.is_none()
20298                            && operation.values.as_ref().is_some_and(|values| {
20299                                table
20300                                    .columns
20301                                    .iter()
20302                                    .all(|column| values.contains_key(&column.name))
20303                            })
20304                        {
20305                            let key = serde_json::to_string(&(
20306                                operation.table.clone(),
20307                                operation.row_id.clone(),
20308                            ))
20309                            .expect("row key serialization");
20310                            if let Some(Value::Object(values)) = initial.get(&key) {
20311                                self.apply_outbox_ops(&[OutboxOp {
20312                                    values: Some(values.clone()),
20313                                    ..operation.clone()
20314                                }])?;
20315                            }
20316                        }
20317                    }
20318                }
20319                self.apply_outbox_ops(
20320                    commit
20321                        .ops
20322                        .iter()
20323                        .filter(|op| names.contains(op.table.as_str())),
20324                )?;
20325            }
20326            for commit in &self.outbox {
20327                self.apply_outbox_ops(
20328                    commit
20329                        .ops
20330                        .iter()
20331                        .filter(|op| names.contains(op.table.as_str())),
20332                )?;
20333            }
20334            for table in &tables {
20335                for index in &table.fts_indexes {
20336                    self.rebuild_fts_projection(table, index)?;
20337                    self.create_fts_triggers(table, index)?;
20338                }
20339            }
20340            self.conn
20341                .execute_batch("RELEASE syncular_overlay")
20342                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20343            Ok(())
20344        })();
20345        if rebuilt.is_err() {
20346            self.conn
20347                .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
20348                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20349        } else {
20350            self.overlay_dirty.set(false);
20351        }
20352        rebuilt
20353    }
20354
20355    /// §7.1 replay: every operation is applied over the current visible
20356    /// state. An operation that no longer applies (a server row now holds its
20357    /// unique value) stays pending and invisible until a later replay admits
20358    /// it or the server answers its push.
20359    fn apply_outbox_ops<'a>(
20360        &self,
20361        ops: impl IntoIterator<Item = &'a OutboxOp>,
20362    ) -> Result<(), String> {
20363        #[cfg(feature = "bench-internals")]
20364        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
20365        for op in ops {
20366            #[cfg(feature = "bench-internals")]
20367            if let Some(phase) = &mut phase {
20368                phase.unit();
20369            }
20370            if op.upsert {
20371                if let (Some(table), Some(values)) = (
20372                    self.schema
20373                        .table(&op.table)
20374                        .filter(|table| table.indexes.iter().any(|index| index.unique)),
20375                    op.values.as_ref(),
20376                ) {
20377                    let mut intended: Map<String, Value> = self
20378                        .visible_row(table, &op.row_id)?
20379                        .map(|(row, _)| {
20380                            table
20381                                .columns
20382                                .iter()
20383                                .zip(&row)
20384                                .map(|(column, value)| {
20385                                    (column.name.clone(), column_value_to_json(value))
20386                                })
20387                                .collect()
20388                        })
20389                        .unwrap_or_default();
20390                    intended.extend(values.clone());
20391                    if !self.unique_conflicts(table, &intended, false)?.is_empty() {
20392                        continue;
20393                    }
20394                }
20395            }
20396            self.apply_outbox_op(op)?;
20397        }
20398        Ok(())
20399    }
20400
20401    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
20402        let Some(table) = self.schema.table(&op.table) else {
20403            return Ok(());
20404        };
20405        let visible = visible_table(&table.name);
20406        if !op.upsert {
20407            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
20408            self.conn
20409                .prepare_cached(&sql)
20410                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
20411                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20412            return Ok(());
20413        }
20414        let Some(values) = op.values.as_ref() else {
20415            return Ok(());
20416        };
20417        // §7.1: the overlay applies the operation's PRESENT columns
20418        // over the current local row. A partial operation over an
20419        // absent local row leaves it absent — the server answers it
20420        // with sync.row_deleted or sync.row_missing (§6.2).
20421        let full = table
20422            .columns
20423            .iter()
20424            .all(|column| values.contains_key(&column.name));
20425        let local = self.visible_row(table, &op.row_id)?;
20426        if local.is_none() && !full {
20427            return Ok(());
20428        }
20429        let mut row: Row = Vec::with_capacity(table.columns.len());
20430        for (index, column) in table.columns.iter().enumerate() {
20431            let incoming = match values.get(&column.name) {
20432                Some(raw) => json_to_column_value(
20433                    if raw.get("$bytes").is_some()
20434                        && table
20435                            .encrypted_columns
20436                            .iter()
20437                            .any(|encrypted| encrypted.index == index)
20438                    {
20439                        &table.wire_columns[index]
20440                    } else {
20441                        column
20442                    },
20443                    Some(raw),
20444                ),
20445                None => Ok(local
20446                    .as_ref()
20447                    .and_then(|(values, _)| values.get(index).cloned())
20448                    .flatten()),
20449            };
20450            row.push(incoming.map_err(|_| {
20451                "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
20452            })?);
20453        }
20454        let version = local.as_ref().map_or(-1, |(_, version)| *version);
20455        self.write_row(&visible, &table.name, &row, version)
20456            .map_err(|error| {
20457                if error.starts_with("UNIQUE constraint failed:") {
20458                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
20459                } else {
20460                    error
20461                }
20462            })
20463    }
20464
20465    /// The visible (optimistic) row's values plus version, or `None` when the
20466    /// row is absent from the overlay.
20467    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
20468        self.stored_row(table, row_id, false)
20469    }
20470
20471    fn stored_row(
20472        &self,
20473        table: &TableSchema,
20474        row_id: &str,
20475        base: bool,
20476    ) -> Result<Option<(Row, i64)>, String> {
20477        let sql = format!(
20478            "SELECT * FROM {} WHERE {}",
20479            if base {
20480                base_table(&table.name)
20481            } else {
20482                visible_table(&table.name)
20483            },
20484            row_id_predicate(table)
20485        );
20486        let mut stmt = self
20487            .conn
20488            .prepare_cached(&sql)
20489            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20490        let mut rows = stmt
20491            .query(rusqlite::params![row_id])
20492            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20493        let Some(row) = rows
20494            .next()
20495            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20496        else {
20497            return Ok(None);
20498        };
20499        let mut values = Vec::with_capacity(table.columns.len());
20500        for (index, column) in table.columns.iter().enumerate() {
20501            let raw = row
20502                .get_ref(index)
20503                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20504            if let rusqlite::types::ValueRef::Blob(bytes) = raw {
20505                if table
20506                    .encrypted_columns
20507                    .iter()
20508                    .any(|encrypted| encrypted.index == index)
20509                {
20510                    values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
20511                    continue;
20512                }
20513            }
20514            let json = sql_ref_to_json(column, raw);
20515            values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
20516                "sync.local_corrupt: persisted local row value is invalid".to_owned()
20517            })?);
20518        }
20519        let version: i64 = row
20520            .get(table.columns.len())
20521            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20522        Ok(Some((values, version)))
20523    }
20524
20525    // -- realtime (§8) ---------------------------------------------------------------
20526
20527    /// §8.8: the configured realtime policy.
20528    #[must_use]
20529    pub fn realtime_policy(&self) -> RealtimePolicy {
20530        self.realtime_policy
20531    }
20532
20533    /// §8.8: the explicit realtime availability state.
20534    #[must_use]
20535    pub fn realtime_state(&self) -> RealtimeState {
20536        self.realtime_state
20537    }
20538
20539    /// §8.8: set the realtime policy. Refused while the socket is connected:
20540    /// a policy change never silently re-routes an ownership state.
20541    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
20542        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
20543            return Err(
20544                "sync.invalid_request: disconnect realtime before changing the realtime policy"
20545                    .to_owned(),
20546            );
20547        }
20548        self.realtime_policy = policy;
20549        match policy {
20550            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
20551            RealtimePolicy::Required | RealtimePolicy::Optional
20552                if self.realtime_state == RealtimeState::Disabled =>
20553            {
20554                self.set_realtime_state(RealtimeState::Disconnected, None);
20555            }
20556            RealtimePolicy::Required | RealtimePolicy::Optional => {}
20557        }
20558        Ok(())
20559    }
20560
20561    /// Record an availability transition, keeping the reason code bounded and
20562    /// code-like (§7.6) and clearing it where it cannot apply.
20563    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
20564        self.realtime_state = state;
20565        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
20566        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
20567            self.realtime_reason_code = None;
20568            self.realtime_retry_delay_ms = None;
20569        }
20570    }
20571
20572    #[must_use]
20573    fn realtime_connected(&self) -> bool {
20574        self.realtime_state == RealtimeState::Connected
20575    }
20576
20577    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
20578        if !self.transport_enabled {
20579            return Err("sync.offline: transport gate is closed".into());
20580        }
20581        if self.realtime_policy == RealtimePolicy::Off {
20582            return Err(
20583                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
20584            );
20585        }
20586        if self.realtime_connected() {
20587            return Ok(());
20588        }
20589        self.realtime_state = RealtimeState::Connecting;
20590        match transport.realtime_connect_for_client(&self.client_id) {
20591            Ok(()) => {
20592                self.set_realtime_state(RealtimeState::Connected, None);
20593                Ok(())
20594            }
20595            Err(error) => {
20596                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
20597                Err(format!("{}: {}", error.code, error.message))
20598            }
20599        }
20600    }
20601
20602    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
20603        self.cancel_sync_round();
20604        // A deliberately disconnected or policy-disabled client has nothing
20605        // to release; a `lost` socket still does (the host owns the handle).
20606        if self.realtime_state == RealtimeState::Disconnected
20607            || self.realtime_state == RealtimeState::Disabled
20608        {
20609            return;
20610        }
20611        let _ = transport.realtime_close();
20612        self.set_realtime_state(
20613            if self.realtime_policy == RealtimePolicy::Off {
20614                RealtimeState::Disabled
20615            } else {
20616                RealtimeState::Disconnected
20617            },
20618            None,
20619        );
20620        self.presence.clear(); // §8.6.1: presence is per-connection
20621    }
20622
20623    /// §8.8: the host observed the socket end without disconnecting it (a
20624    /// peer close, an I/O error, a failed send; the native transport reports
20625    /// these as `Inbound::Disconnected`). Releases the transport, moves a
20626    /// `Connected` state to `Lost` with the transport's code as the reason, and schedules
20627    /// one background retry intent so the host reconnects and catches up.
20628    pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
20629        if self.realtime_state != RealtimeState::Connected {
20630            return;
20631        }
20632        self.cancel_sync_round();
20633        let _ = transport.realtime_close();
20634        self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
20635        self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
20636        self.presence.clear();
20637    }
20638
20639    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
20640    /// document for `scope_key`. Requires a live socket; the document is
20641    /// ephemeral (lost on disconnect). Authorization is the connection's
20642    /// registration (§8.6.3) — an unheld key is rejected loudly by the
20643    /// server with `presence.forbidden`.
20644    pub fn set_presence(
20645        &mut self,
20646        transport: &mut dyn Transport,
20647        scope_key: &str,
20648        doc: Option<&Value>,
20649    ) -> Result<(), String> {
20650        if !self.transport_enabled {
20651            return Err("sync.offline: transport gate is closed".into());
20652        }
20653        if !self.realtime_connected() {
20654            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
20655        }
20656        let text = encode_presence_publish(scope_key, doc);
20657        transport
20658            .realtime_send(&text)
20659            .map_err(|e| format!("{}: {}", e.code, e.message))
20660    }
20661
20662    /// §8.6: the peers currently present on a scope key (ephemeral).
20663    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
20664        self.presence
20665            .get(scope_key)
20666            .map(|peers| peers.values().cloned().collect())
20667            .unwrap_or_default()
20668    }
20669
20670    /// §8.6 apply an inbound presence fanout to the local map.
20671    fn apply_presence(
20672        &mut self,
20673        scope_key: String,
20674        kind: Option<PresenceKind>,
20675        actor_id: Option<String>,
20676        client_id: Option<String>,
20677        doc: Option<Value>,
20678        error: Option<String>,
20679    ) {
20680        // The publisher-directed error variant is out-of-band; nothing to
20681        // record in the peer map.
20682        if error.is_some() {
20683            return;
20684        }
20685        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
20686            return;
20687        };
20688        let peer_key = format!("{actor_id} {client_id}");
20689        match kind {
20690            PresenceKind::Leave => {
20691                if let Some(peers) = self.presence.get_mut(&scope_key) {
20692                    peers.remove(&peer_key);
20693                    if peers.is_empty() {
20694                        self.presence.remove(&scope_key);
20695                    }
20696                }
20697            }
20698            _ => {
20699                let doc = match doc {
20700                    Some(Value::Object(_)) => doc.unwrap(),
20701                    _ => return,
20702                };
20703                self.presence.entry(scope_key).or_default().insert(
20704                    peer_key,
20705                    PresencePeer {
20706                        actor_id,
20707                        client_id,
20708                        doc,
20709                    },
20710                );
20711            }
20712        }
20713    }
20714
20715    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
20716    pub fn on_realtime_text(&mut self, text: &str) {
20717        if self.stopped || !self.transport_enabled || self.security_preflight {
20718            return;
20719        }
20720        match parse_control(text) {
20721            Ok(ControlMessage::Hello { requires_sync, .. }) => {
20722                if requires_sync {
20723                    // §8.1: pull before trusting the socket for continuity.
20724                    self.set_sync_needed(true, true);
20725                }
20726            }
20727            Ok(ControlMessage::Presence {
20728                scope_key,
20729                kind,
20730                actor_id,
20731                client_id,
20732                doc,
20733                error,
20734                ..
20735            }) => {
20736                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
20737            }
20738            Ok(ControlMessage::Wake { .. }) => {
20739                // §8.3: any wake-up means "run a pull soon", never data.
20740                self.set_sync_needed(true, true);
20741            }
20742            _ => {}
20743        }
20744    }
20745
20746    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
20747    /// a pull response per section; an unapplied delta is a wake-up.
20748    /// Mailbox hosts send durable acknowledgements on their I/O executor.
20749    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
20750        let mut transport = crate::round::DownloadResults::default();
20751        self.on_realtime_binary(&mut transport, bytes);
20752        transport.controls
20753    }
20754
20755    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
20756        if self.stopped || !self.transport_enabled || self.security_preflight {
20757            return;
20758        }
20759        #[cfg(feature = "bench-internals")]
20760        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
20761        let message = match decode_message(bytes) {
20762            Ok(m) if m.msg_kind == MsgKind::Response => m,
20763            _ => {
20764                self.set_sync_needed(true, true);
20765                return;
20766            }
20767        };
20768        #[cfg(feature = "bench-internals")]
20769        drop(decode_phase);
20770        let coalesced_deletes =
20771            coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
20772                if status != SubStatus::Active {
20773                    return false;
20774                }
20775                let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
20776                    return false;
20777                };
20778                sub.state == SubState::Active
20779                    && sub.bootstrap_state.is_none()
20780                    && sub.synced_once
20781                    && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
20782            });
20783        let mut frames = message.frames.into_iter();
20784        let mut applied_cursor: Option<i64> = None;
20785        let mut any_covered = false;
20786        let mut dropped = self.begin_coalesced_apply().is_err();
20787        while let Some(frame) = frames.next() {
20788            let Frame::SubStart {
20789                id,
20790                status,
20791                effective_scopes,
20792                ..
20793            } = frame
20794            else {
20795                continue;
20796            };
20797            let mut body = Vec::new();
20798            let mut next_cursor: Option<i64> = None;
20799            for inner in frames.by_ref() {
20800                match inner {
20801                    Frame::SubEnd {
20802                        next_cursor: nc, ..
20803                    } => {
20804                        next_cursor = Some(nc);
20805                        break;
20806                    }
20807                    Frame::Unknown { .. } => {}
20808                    other => body.push(other),
20809                }
20810            }
20811            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
20812                dropped = true;
20813                continue;
20814            };
20815            let sub = &self.subs[sub_index];
20816            // §8.2: only locally active, not mid-bootstrap subscriptions
20817            // apply; skipped sections are repaired by the next pull.
20818            if status != SubStatus::Active
20819                || sub.state != SubState::Active
20820                || sub.bootstrap_state.is_some()
20821                || !sub.synced_once
20822            {
20823                dropped = true;
20824                continue;
20825            }
20826            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
20827                // Idempotent redelivery of an already-covered window.
20828                any_covered = true;
20829                continue;
20830            }
20831            let previous_effective = self.subs[sub_index].effective.clone();
20832            let previous_cursor = self.subs[sub_index].cursor;
20833            self.subs[sub_index].effective = Some(effective_scopes);
20834            let mut failed = false;
20835            for inner in body {
20836                match inner {
20837                    Frame::Commit {
20838                        tables,
20839                        changes,
20840                        commit_seq,
20841                        ..
20842                    } => {
20843                        if self
20844                            .apply_commit_frame_coalesced(
20845                                &tables,
20846                                changes,
20847                                commit_seq,
20848                                &coalesced_deletes,
20849                            )
20850                            .is_err()
20851                        {
20852                            failed = true;
20853                            break;
20854                        }
20855                    }
20856                    _ => {
20857                        failed = true;
20858                        break;
20859                    }
20860                }
20861            }
20862            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
20863                self.apply_sub_end(sub_index, cursor, None)
20864                    .ok()
20865                    .map(|()| cursor)
20866            });
20867            let Some(next_cursor) = committed else {
20868                self.subs[sub_index].effective = previous_effective;
20869                self.subs[sub_index].cursor = previous_cursor;
20870                dropped = true;
20871                continue;
20872            };
20873            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
20874        }
20875        // §1.4 rule 4: the ack follows the COMMIT. A failed COMMIT restored
20876        // the cursors, so the delta counts as a wake-up.
20877        if self.finish_coalesced_apply().is_err() {
20878            applied_cursor = None;
20879            dropped = true;
20880        }
20881        if let Some(cursor) = applied_cursor {
20882            // §8.2 ack point: the highest applied SUB_END.nextCursor.
20883            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20884            let _ = transport.realtime_send(&ack);
20885        } else if !any_covered || dropped {
20886            // §8.2: a delta not applied at all is treated as a wake-up.
20887            self.set_sync_needed(true, true);
20888        }
20889    }
20890
20891    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
20892    /// cursor across active, non-bootstrapping subscriptions that have
20893    /// synced at least once. No such subscription, no ack.
20894    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
20895        if !self.realtime_connected() {
20896            return;
20897        }
20898        let floor = self
20899            .subs
20900            .iter()
20901            .filter(|s| {
20902                s.state == SubState::Active
20903                    && s.bootstrap_state.is_none()
20904                    && s.synced_once
20905                    && s.cursor >= 0
20906            })
20907            .map(|s| s.cursor)
20908            .min();
20909        if let Some(cursor) = floor {
20910            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20911            let _ = transport.realtime_send(&ack);
20912        }
20913    }
20914}
20915
20916#[cfg(test)]
20917mod previous_version_wiring_tests {
20918    use super::*;
20919    use serde_json::{json, Value};
20920
20921    fn previous_version_schema(version: i32) -> Value {
20922        json!({
20923            "version": version,
20924            "tables": [{
20925                "name": "tasks",
20926                "primaryKey": "id",
20927                "columns": [
20928                    { "name": "id", "type": "string", "nullable": false },
20929                    { "name": "note", "type": "string", "nullable": true }
20930                ],
20931                "scopes": []
20932            }]
20933        })
20934    }
20935
20936    fn previous_version_limits() -> crate::api::ClientLimits {
20937        crate::api::ClientLimits {
20938            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
20939                enabled: true,
20940                ..Default::default()
20941            }),
20942            ..Default::default()
20943        }
20944    }
20945
20946    /// The configuration the live client carries, for a direct capture call.
20947    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
20948        previous_version_limits()
20949            .previous_version_context
20950            .expect("configured")
20951    }
20952
20953    /// Capture the container the way a bump would, so a read test starts from a
20954    /// container this client's own state will then decide about.
20955    fn recapture_previous_version(client: &SyncClient) {
20956        let replica_path = client
20957            .previous_version_replica_path()
20958            .expect("file-backed replica");
20959        let outcome = crate::previous_version::capture_previous_version_from_replica(
20960            &client.conn,
20961            &replica_path,
20962            2,
20963            2,
20964            &previous_version_config(),
20965            1,
20966        )
20967        .expect("capture");
20968        assert!(matches!(
20969            outcome,
20970            crate::previous_version::CaptureOutcome::Captured(_)
20971        ));
20972    }
20973
20974    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
20975        Subscription {
20976            id: "wiring-sub".to_owned(),
20977            table: "tasks".to_owned(),
20978            requested: Vec::new(),
20979            params: None,
20980            cursor,
20981            bootstrap_state: bootstrap_state.map(str::to_owned),
20982            state,
20983            reason_code: None,
20984            effective: None,
20985            synced_once: false,
20986        }
20987    }
20988
20989    #[test]
20990    fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
20991        let path =
20992            std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
20993        let path_str = path.to_str().expect("temp path");
20994        let schema = previous_version_schema(3);
20995        let mut older = previous_version_schema(2);
20996        older["tables"][0]["columns"] =
20997            json!([{ "name": "id", "type": "string", "nullable": false }]);
20998        let mut client = SyncClient::open_path(
20999            "downgrade".into(),
21000            &previous_version_schema(1),
21001            previous_version_limits(),
21002            path_str,
21003        )
21004        .expect("fresh");
21005        client
21006            .mutate(vec![Mutation::Upsert {
21007                table: "tasks".into(),
21008                values: Map::from_iter([
21009                    ("id".into(), json!("accepted")),
21010                    ("note".into(), json!("old")),
21011                ]),
21012                base_version: None,
21013            }])
21014            .expect("v1 write");
21015        client
21016            .recreate_with_schema(&schema)
21017            .expect("upgrade captures context");
21018        let commit = client
21019            .mutate(vec![Mutation::Upsert {
21020                table: "tasks".into(),
21021                values: Map::from_iter([
21022                    ("id".into(), json!("queued")),
21023                    ("note".into(), json!("v3-only")),
21024                ]),
21025                base_version: None,
21026            }])
21027            .expect("queued v3 write");
21028        let changes = client.conn.total_changes();
21029        let outbox = client.pending_commit_ids();
21030        assert!(outbox.contains(&commit));
21031        let descriptor = client.get_meta("localSchemaDescriptor");
21032        let container = format!(
21033            "{path_str}{}",
21034            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
21035        );
21036        let context = std::fs::read(&container).expect("captured context");
21037        assert!(client
21038            .recreate_with_schema(&older)
21039            .expect_err("refuse recreate")
21040            .starts_with("client.schema_downgrade:"));
21041        assert_eq!(client.schema.version, 3);
21042        assert_eq!(client.conn.total_changes(), changes);
21043        assert_eq!(client.pending_commit_ids(), outbox);
21044        assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
21045        assert_eq!(
21046            client
21047                .conn
21048                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21049                    row.get::<_, String>(0)
21050                })
21051                .expect("queued row"),
21052            "v3-only"
21053        );
21054        assert_eq!(std::fs::read(&container).expect("context remains"), context);
21055        drop(client);
21056        let before = std::fs::read(&path).expect("replica bytes");
21057        // open_path uses DELETE journaling. The identity open must refuse
21058        // before its normal WAL configuration changes this database.
21059        for entry in 0..3 {
21060            let error = match entry {
21061                0 => SyncClient::open_path(
21062                    "downgrade".into(),
21063                    &older,
21064                    previous_version_limits(),
21065                    path_str,
21066                ),
21067                1 => SyncClient::open_path_with_identity(
21068                    None,
21069                    &older,
21070                    previous_version_limits(),
21071                    path_str,
21072                ),
21073                _ => SyncClient::with_connection(
21074                    "downgrade".into(),
21075                    &older,
21076                    previous_version_limits(),
21077                    Connection::open(&path).expect("connection"),
21078                ),
21079            }
21080            .err()
21081            .expect("refuse open");
21082            assert!(error.starts_with("client.schema_downgrade:"));
21083            let conn = Connection::open(&path).expect("inspect");
21084            assert_eq!(
21085                conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21086                    .expect("journal mode"),
21087                "delete"
21088            );
21089            drop(conn);
21090            assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
21091            assert_eq!(
21092                std::fs::read(&container).expect("context unchanged"),
21093                context
21094            );
21095        }
21096        let reopened = SyncClient::open_path(
21097            "downgrade".into(),
21098            &schema,
21099            previous_version_limits(),
21100            path_str,
21101        )
21102        .expect("compatible reopen");
21103        assert_eq!(reopened.pending_commit_ids(), outbox);
21104        assert_eq!(
21105            reopened
21106                .conn
21107                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21108                    row.get::<_, String>(0)
21109                })
21110                .expect("queued row remains"),
21111            "v3-only"
21112        );
21113        reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
21114        reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21115        drop(reopened);
21116        let legacy = SyncClient::open_path(
21117            "downgrade".into(),
21118            &schema,
21119            previous_version_limits(),
21120            path_str,
21121        )
21122        .expect("legacy no-marker reopen");
21123        assert_eq!(legacy.pending_commit_ids(), outbox);
21124        assert!(!legacy.upgrading());
21125        assert_eq!(
21126            legacy
21127                .conn
21128                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21129                    row.get::<_, String>(0)
21130                })
21131                .expect("legacy row remains"),
21132            "v3-only"
21133        );
21134        drop(legacy);
21135        std::fs::remove_file(path).expect("remove replica");
21136        let _ = std::fs::remove_file(container);
21137    }
21138
21139    #[test]
21140    fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
21141        for marker in [
21142            "''",
21143            "'bad'",
21144            "'0'",
21145            "'-1'",
21146            "'2.5'",
21147            "'03'",
21148            "'3\n'",
21149            "' 3'",
21150            "'+3'",
21151            "'3e0'",
21152            "'2147483648'",
21153            "NULL",
21154            "X'33'",
21155        ] {
21156            let conn = Connection::open_in_memory().expect("connection");
21157            conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
21158            let error = SyncClient::with_connection(
21159                "marker".into(),
21160                &previous_version_schema(3),
21161                ClientLimits::default(),
21162                conn,
21163            )
21164            .err()
21165            .expect("refuse marker");
21166            assert!(
21167                error.starts_with("sync.local_corrupt:"),
21168                "{marker}: {error}"
21169            );
21170        }
21171        for fault in [
21172            "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
21173            "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
21174        ] {
21175            let mut client = SyncClient::new(
21176                "marker".into(),
21177                &previous_version_schema(3),
21178                ClientLimits::default(),
21179            )
21180            .expect("fresh");
21181            let commit = client
21182                .mutate(vec![Mutation::Upsert {
21183                    table: "tasks".into(),
21184                    values: Map::from_iter([("id".into(), json!("queued"))]),
21185                    base_version: None,
21186                }])
21187                .expect("queued write");
21188            client.conn.execute_batch(fault).expect("marker fault");
21189            let changes = client.conn.total_changes();
21190            let error = client
21191                .recreate_with_schema(&previous_version_schema(4))
21192                .expect_err("refuse corrupt marker");
21193            assert!(error.starts_with("sync.local_corrupt:"));
21194            assert_eq!(client.conn.total_changes(), changes);
21195            assert_eq!(client.schema.version, 3);
21196            assert_eq!(client.pending_commit_ids(), vec![commit]);
21197        }
21198        for setup in [
21199            "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
21200            "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
21201        ] {
21202            let path =
21203                std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
21204            let conn = Connection::open(&path).expect("connection");
21205            conn.execute_batch(setup)
21206                .expect("unreadable metadata fixture");
21207            drop(conn);
21208            let before = std::fs::read(&path).expect("before");
21209            let error = SyncClient::open_path_with_identity(
21210                None,
21211                &previous_version_schema(3),
21212                ClientLimits::default(),
21213                path.to_str().expect("path"),
21214            )
21215            .err()
21216            .expect("refuse unreadable marker");
21217            assert!(error.starts_with("sync.local_corrupt:"));
21218            assert_eq!(std::fs::read(&path).expect("after"), before);
21219            std::fs::remove_file(path).expect("remove fixture");
21220        }
21221    }
21222
21223    std::thread_local! {
21224        /// The pending concurrent upgrade a busy handler commits, so a lock
21225        /// collision deterministically becomes the marker race under test.
21226        static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
21227            const { RefCell::new(None) };
21228    }
21229
21230    fn commit_concurrent_upgrade(attempts: i32) -> bool {
21231        CONCURRENT_UPGRADE.with(|slot| {
21232            if let Some(banner) = slot.borrow_mut().take() {
21233                banner
21234                    .execute_batch(
21235                        "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
21236                    )
21237                    .expect("commit the concurrent upgrade");
21238            }
21239        });
21240        attempts < 10
21241    }
21242
21243    fn journal_mode(conn: &Connection) -> String {
21244        conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21245            .expect("journal mode")
21246    }
21247
21248    fn temp_replica(label: &str) -> std::path::PathBuf {
21249        std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
21250    }
21251
21252    #[test]
21253    fn file_identity_constructor_requires_the_returned_wal_mode() {
21254        for path in [":memory:", ""] {
21255            let error = SyncClient::open_path_with_identity(
21256                None,
21257                &previous_version_schema(1),
21258                ClientLimits::default(),
21259                path,
21260            )
21261            .err()
21262            .expect("temporary SQLite databases cannot enter WAL mode");
21263            assert_eq!(
21264                error,
21265                "sync.invalid_request: file-backed client requires WAL journal mode"
21266            );
21267        }
21268        let path = temp_replica("confirmed-wal");
21269        let client = SyncClient::open_path_with_identity(
21270            None,
21271            &previous_version_schema(1),
21272            ClientLimits::default(),
21273            path.to_str().unwrap(),
21274        )
21275        .unwrap();
21276        assert_eq!(journal_mode(&client.conn), "wal");
21277        drop(client);
21278        std::fs::remove_file(path).unwrap();
21279    }
21280
21281    #[test]
21282    fn non_wal_identity_open_is_accepted_when_wal_is_not_required() {
21283        for path in [":memory:", ""] {
21284            let client = SyncClient::open_path_with_identity_journal(
21285                None,
21286                &previous_version_schema(1),
21287                ClientLimits::default(),
21288                path,
21289                false,
21290            )
21291            .expect("a host without WAL can still open the identity constructor");
21292            assert_ne!(journal_mode(&client.conn), "wal");
21293            assert_eq!(
21294                client
21295                    .conn
21296                    .query_row("PRAGMA synchronous", [], |row| row.get::<_, i64>(0))
21297                    .expect("synchronous"),
21298                2,
21299                "every identity open pins FULL durability"
21300            );
21301        }
21302    }
21303
21304    #[test]
21305    fn reset_replica_deletes_the_database_and_its_sidecars_through_the_vfs() {
21306        let path = temp_replica("reset");
21307        let path_str = path.to_str().expect("path");
21308        let client = SyncClient::open_path(
21309            "reset-replica".into(),
21310            &previous_version_schema(1),
21311            ClientLimits::default(),
21312            path_str,
21313        )
21314        .expect("replica");
21315        let original_id = client.client_id().to_owned();
21316        drop(client);
21317        for suffix in ["-journal", "-wal", "-shm"] {
21318            std::fs::write(format!("{path_str}{suffix}"), b"stale sidecar").expect("sidecar");
21319        }
21320
21321        SyncClient::reset_replica(path_str).expect("reset through the VFS");
21322
21323        for suffix in ["", "-journal", "-wal", "-shm"] {
21324            assert!(
21325                !std::path::Path::new(&format!("{path_str}{suffix}")).exists(),
21326                "reset removes the replica file {suffix:?}"
21327            );
21328        }
21329        let reopened = SyncClient::open_path_with_identity(
21330            None,
21331            &previous_version_schema(1),
21332            ClientLimits::default(),
21333            path_str,
21334        )
21335        .expect("fresh replica");
21336        assert_ne!(reopened.client_id(), original_id);
21337        assert!(reopened
21338            .query("SELECT id FROM tasks", &[])
21339            .expect("empty")
21340            .is_empty());
21341        drop(reopened);
21342        assert_eq!(
21343            SyncClient::reset_replica(":memory:").expect_err("memory has nothing to reset"),
21344            "sync.invalid_request: a memory replica has nothing to reset"
21345        );
21346    }
21347
21348    #[test]
21349    fn non_positive_generated_schema_versions_are_refused_before_storage() {
21350        for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
21351            let mut schema = previous_version_schema(1);
21352            schema["version"] = version.clone();
21353            let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
21354                .err()
21355                .expect("refuse an out-of-range generated version");
21356            assert_eq!(
21357                error,
21358                "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
21359            );
21360            // The file-backed seam refuses before the replica gains a table.
21361            let path = temp_replica("bad-version");
21362            let path_str = path.to_str().expect("path");
21363            for identity in [false, true] {
21364                let error = if identity {
21365                    SyncClient::open_path_with_identity(
21366                        None,
21367                        &schema,
21368                        ClientLimits::default(),
21369                        path_str,
21370                    )
21371                } else {
21372                    SyncClient::open_path(
21373                        "bad-version".into(),
21374                        &schema,
21375                        ClientLimits::default(),
21376                        path_str,
21377                    )
21378                }
21379                .err()
21380                .expect("refuse before storage");
21381                assert!(
21382                    error.starts_with("sync.invalid_request:"),
21383                    "{version}: {error}"
21384                );
21385                assert!(
21386                    !path.exists(),
21387                    "{version}: a refused constructor creates no replica file"
21388                );
21389            }
21390        }
21391        // A missing version is the same request error, not a parse failure.
21392        let mut schema = previous_version_schema(1);
21393        schema
21394            .as_object_mut()
21395            .expect("schema object")
21396            .remove("version");
21397        let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
21398            .err()
21399            .expect("refuse a missing version");
21400        assert!(error.starts_with("sync.invalid_request:"), "{error}");
21401    }
21402
21403    #[test]
21404    fn a_duplicated_schema_marker_is_refused_without_a_reset() {
21405        let path = temp_replica("duplicate-marker");
21406        let path_str = path.to_str().expect("path");
21407        {
21408            // A hand-built/legacy metadata table without its primary key, with
21409            // the older marker first: `query_row` would pick it and reset.
21410            let conn = Connection::open(path_str).expect("fixture");
21411            conn.execute_batch(
21412                "CREATE TABLE _syncular_meta(key TEXT, value);
21413                 INSERT INTO _syncular_meta VALUES
21414                   ('localSchemaVersion', '1'),
21415                   ('localSchemaVersion', '2'),
21416                   ('clientId', 'duplicate');",
21417            )
21418            .expect("duplicate marker fixture");
21419        }
21420        let error = SyncClient::open_path_with_identity(
21421            None,
21422            &previous_version_schema(3),
21423            ClientLimits::default(),
21424            path_str,
21425        )
21426        .err()
21427        .expect("refuse a duplicated marker");
21428        assert!(error.starts_with("sync.local_corrupt:"), "{error}");
21429        let probe = Connection::open(path_str).expect("probe");
21430        assert_eq!(
21431            probe
21432                .query_row(
21433                    "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
21434                    [],
21435                    |row| row.get::<_, i64>(0),
21436                )
21437                .expect("marker rows"),
21438            2,
21439            "the duplicated rows are left for the operator, not collapsed"
21440        );
21441        assert_eq!(
21442            probe
21443                .query_row(
21444                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
21445                    [],
21446                    |row| row.get::<_, i64>(0),
21447                )
21448                .expect("synced tables"),
21449            0,
21450            "a duplicated marker never drives the reset that recreates v3 tables"
21451        );
21452        assert_eq!(
21453            journal_mode(&probe),
21454            "delete",
21455            "the refusal leaves the journal mode alone"
21456        );
21457        drop(probe);
21458        std::fs::remove_file(path).expect("remove replica");
21459    }
21460
21461    #[test]
21462    fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
21463        let path = temp_replica("refused-wal");
21464        let path_str = path.to_str().expect("path");
21465        // `open_path` leaves the default journal mode, so a refused open is
21466        // the only thing that could switch this replica to WAL.
21467        drop(
21468            SyncClient::open_path(
21469                "owner".into(),
21470                &previous_version_schema(2),
21471                ClientLimits::default(),
21472                path_str,
21473            )
21474            .expect("v2 replica"),
21475        );
21476        let before = std::fs::read(&path).expect("before");
21477        let error = SyncClient::open_path_with_identity(
21478            Some("intruder".into()),
21479            &previous_version_schema(2),
21480            ClientLimits::default(),
21481            path_str,
21482        )
21483        .err()
21484        .expect("refuse the identity mismatch");
21485        assert!(error.starts_with("client.identity_mismatch:"), "{error}");
21486        let probe = Connection::open(path_str).expect("probe");
21487        assert_eq!(
21488            journal_mode(&probe),
21489            "delete",
21490            "a refused open must not persist WAL"
21491        );
21492        assert_eq!(std::fs::read(&path).expect("after"), before);
21493        assert_eq!(
21494            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21495            Some("2")
21496        );
21497        drop(probe);
21498        std::fs::remove_file(path).expect("remove replica");
21499    }
21500
21501    #[test]
21502    fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
21503        let path = temp_replica("marker-race");
21504        let path_str = path.to_str().expect("path");
21505        drop(
21506            SyncClient::open_path_with_identity(
21507                Some("stale".into()),
21508                &previous_version_schema(1),
21509                ClientLimits::default(),
21510                path_str,
21511            )
21512            .expect("v1 replica"),
21513        );
21514        // Another process holds the write lock with an uncommitted upgrade; a
21515        // stale open reads the v1 marker first and only then meets the lock.
21516        // The first busy therefore lands after this replica became v2.
21517        let banner = Connection::open(path_str).expect("banner");
21518        banner
21519            .execute_batch(
21520                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
21521            )
21522            .expect("pending upgrade");
21523        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
21524        let conn = Connection::open(path_str).expect("stale connection");
21525        conn.busy_handler(Some(commit_concurrent_upgrade))
21526            .expect("busy handler");
21527        let error = SyncClient::with_connection(
21528            "stale".into(),
21529            &previous_version_schema(1),
21530            ClientLimits::default(),
21531            conn,
21532        )
21533        .err()
21534        .expect("a concurrent upgrade must refuse the stale open");
21535        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
21536        let probe = Connection::open(path_str).expect("probe");
21537        assert_eq!(
21538            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21539            Some("2"),
21540            "the concurrent upgrade survives"
21541        );
21542        drop(probe);
21543        std::fs::remove_file(path).expect("remove replica");
21544    }
21545
21546    #[test]
21547    fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
21548        let path = temp_replica("recreate-race");
21549        let path_str = path.to_str().expect("path");
21550        let mut client = SyncClient::open_path_with_identity(
21551            Some("stale".into()),
21552            &previous_version_schema(1),
21553            ClientLimits::default(),
21554            path_str,
21555        )
21556        .expect("v1 replica");
21557        client
21558            .mutate(vec![Mutation::Upsert {
21559                table: "tasks".into(),
21560                values: Map::from_iter([("id".into(), json!("queued"))]),
21561                base_version: None,
21562            }])
21563            .expect("queued write");
21564        let commit = client.pending_commit_ids();
21565        let banner = Connection::open(path_str).expect("banner");
21566        banner
21567            .execute_batch(
21568                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
21569            )
21570            .expect("pending upgrade");
21571        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
21572        client
21573            .conn
21574            .busy_handler(Some(commit_concurrent_upgrade))
21575            .expect("busy handler");
21576        let error = client
21577            .recreate_with_schema(&previous_version_schema(1))
21578            .expect_err("a concurrent upgrade must refuse the stale recreate");
21579        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
21580        assert_eq!(client.schema.version, 1, "the compiled schema is restored");
21581        assert_eq!(
21582            client.pending_commit_ids(),
21583            commit,
21584            "the outbox is preserved"
21585        );
21586        let probe = Connection::open(path_str).expect("probe");
21587        assert_eq!(
21588            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21589            Some("2")
21590        );
21591        drop(probe);
21592        drop(client);
21593        std::fs::remove_file(path).expect("remove replica");
21594    }
21595
21596    #[test]
21597    fn failed_schema_record_writes_roll_back_recreation() {
21598        for key in [
21599            LOCAL_SCHEMA_VERSION_KEY,
21600            crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
21601        ] {
21602            let mut client = SyncClient::new(
21603                "schema-record".into(),
21604                &previous_version_schema(1),
21605                ClientLimits::default(),
21606            )
21607            .expect("v1");
21608            let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
21609            let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21610            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");
21611            client
21612                .recreate_with_schema(&previous_version_schema(2))
21613                .expect_err("both record writes must propagate failure");
21614            assert_eq!(client.schema.version, 1);
21615            assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
21616            assert_eq!(
21617                client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
21618                descriptor
21619            );
21620            client
21621                .conn
21622                .execute_batch("DROP TRIGGER refuse_schema_record")
21623                .expect("remove fault");
21624            client
21625                .recreate_with_schema(&previous_version_schema(2))
21626                .expect("retry");
21627            assert_eq!(client.schema.version, 2);
21628        }
21629    }
21630
21631    #[test]
21632    fn a_failed_outer_commit_restores_every_reset_side_effect() {
21633        let path = temp_replica("outer-commit");
21634        let path_str = path.to_str().expect("path");
21635        // A rollback-journal replica (no WAL), so a second read connection can
21636        // hold SHARED across the recreated transaction's COMMIT.
21637        let conn = Connection::open(path_str).expect("replica connection");
21638        let mut client = SyncClient::with_connection(
21639            "outer-commit".into(),
21640            &previous_version_schema(1),
21641            previous_version_limits(),
21642            conn,
21643        )
21644        .expect("v1 replica");
21645        assert_eq!(journal_mode(&client.conn), "delete");
21646        let commit = client
21647            .mutate(vec![Mutation::Upsert {
21648                table: "tasks".into(),
21649                values: Map::from_iter([
21650                    ("id".into(), json!("queued")),
21651                    ("note".into(), json!("dropped-by-v2")),
21652                ]),
21653                base_version: None,
21654            }])
21655            .expect("queued write");
21656        client.drain_change_batches();
21657        // v2 drops `note`, so the queued upsert cannot re-encode.
21658        let mut dropped = previous_version_schema(2);
21659        dropped["tables"][0]["columns"] =
21660            json!([{ "name": "id", "type": "string", "nullable": false }]);
21661        // Prior database and memory state: the refused COMMIT must restore all
21662        // of it.
21663        let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
21664        let revision = client.local_revision();
21665        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
21666        let columns: i64 = client
21667            .conn
21668            .query_row(
21669                "SELECT count(*) FROM pragma_table_info('tasks')",
21670                [],
21671                |row| row.get(0),
21672            )
21673            .expect("column count");
21674        let rebuilds = client.overlay_rebuild_count.get();
21675        let prior_subs = client.subs.len();
21676        let prior_changes = client.change_queue.len();
21677        let prior_intents = client.sync_intent_queue.len();
21678        let prior_last_change = client.last_change.clone();
21679        let prior_overlay_dirty = client.overlay_dirty.get();
21680        let prior_sync_needed = client.sync_needed;
21681        let prior_upgrading = client.upgrading;
21682        let prior_stopped = client.stopped;
21683        let prior_schema_floor = client.schema_floor.clone();
21684        let prior_active_round = client.active_round;
21685        // The reader holds SHARED for the whole attempt: the writer takes
21686        // RESERVED, runs the reset, and fails when COMMIT needs EXCLUSIVE.
21687        let reader = Connection::open(path_str).expect("reader");
21688        reader
21689            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
21690            .expect("reader snapshot");
21691        client
21692            .conn
21693            .busy_timeout(std::time::Duration::ZERO)
21694            .expect("busy timeout");
21695        let error = client
21696            .recreate_with_schema(&dropped)
21697            .expect_err("a blocked COMMIT must refuse the recreation");
21698        assert!(error.starts_with("client.storage_busy:"), "{error}");
21699        assert!(
21700            client.overlay_rebuild_count.get() > rebuilds,
21701            "the reset ran to its overlay rebuild inside the transaction"
21702        );
21703        assert!(client.conn.is_autocommit(), "the transaction is released");
21704        // Database: the rolled-back reset left nothing behind.
21705        assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
21706        assert_eq!(client.local_revision(), revision);
21707        assert_eq!(
21708            client.query("SELECT * FROM tasks", &[]).expect("rows"),
21709            rows
21710        );
21711        assert_eq!(
21712            client
21713                .conn
21714                .query_row(
21715                    "SELECT count(*) FROM pragma_table_info('tasks')",
21716                    [],
21717                    |row| { row.get::<_, i64>(0) }
21718                )
21719                .expect("column count"),
21720            columns,
21721            "the previous table layout survives the rollback"
21722        );
21723        assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
21724        // Memory: every side effect of the successful reset is restored with
21725        // the rolled-back database.
21726        assert_eq!(client.schema.version, 1, "compiled schema");
21727        assert_eq!(client.subs.len(), prior_subs);
21728        assert_eq!(client.change_queue.len(), prior_changes);
21729        assert_eq!(client.sync_intent_queue.len(), prior_intents);
21730        assert_eq!(client.last_change, prior_last_change);
21731        assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
21732        assert_eq!(client.sync_needed, prior_sync_needed);
21733        assert_eq!(client.upgrading, prior_upgrading);
21734        assert_eq!(client.stopped, prior_stopped);
21735        assert_eq!(client.schema_floor, prior_schema_floor);
21736        assert_eq!(client.active_round, prior_active_round);
21737        assert!(
21738            client.rejections.is_empty(),
21739            "the dropped-column rejection is rolled back too"
21740        );
21741        // The capture committed in its separate file before the failed replica
21742        // COMMIT. It belongs to v2 and cannot be exposed by the restored v1 core.
21743        let container = crate::previous_version::previous_version_container_path(path_str);
21744        assert!(std::path::Path::new(&container).exists());
21745        // The reader no longer needs its snapshot, so discard can clean the
21746        // replica's audit records along with the stale sibling file.
21747        drop(reader);
21748        let snapshot = client
21749            .previous_version_snapshot(&PreviousVersionReadSpec {
21750                table: "tasks".into(),
21751                ..Default::default()
21752            })
21753            .expect("refuse stale capture");
21754        assert!(!snapshot.available);
21755        assert_eq!(snapshot.current_version, 1);
21756        assert!(!std::path::Path::new(&container).exists());
21757        // The client remains usable: releasing the reader lets the identical
21758        // recreation run to completion, and the reset preserves the
21759        // incompatible intent until the send-time prepass classifies it
21760        // (§7.4.3/§7.4.4: the reset never touches the outbox).
21761        client
21762            .conn
21763            .busy_timeout(std::time::Duration::from_secs(1))
21764            .expect("busy timeout");
21765        client
21766            .recreate_with_schema(&dropped)
21767            .expect("the recreation succeeds once the reader releases");
21768        assert_eq!(client.schema.version, 2);
21769        assert_eq!(
21770            client.pending_commit_ids(),
21771            vec![commit.clone()],
21772            "the reset kept the incompatible commit pending"
21773        );
21774        assert!(
21775            client.rejections.is_empty(),
21776            "no rejection exists before the send-time prepass"
21777        );
21778        // The send boundary classifies and drops it.
21779        client
21780            .prepare_sync_round(false)
21781            .expect("the send prepass runs during round preparation");
21782        assert!(client.pending_commit_ids().is_empty());
21783        assert!(
21784            client
21785                .rejections
21786                .iter()
21787                .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
21788            "the dropped-column commit surfaced as a rejection at send time"
21789        );
21790        client.previous_version_discard();
21791        drop(client);
21792        std::fs::remove_file(path).expect("remove replica");
21793    }
21794
21795    #[test]
21796    fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
21797        let v1 = previous_version_schema(1);
21798        let mut v2 = previous_version_schema(2);
21799        // v2 drops `note`, so the queued `dropped` upsert cannot re-encode.
21800        v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
21801        let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
21802        let dropped = client
21803            .mutate(vec![Mutation::Upsert {
21804                table: "tasks".into(),
21805                values: Map::from_iter([
21806                    ("id".into(), json!("dropped")),
21807                    ("note".into(), json!("gone")),
21808                ]),
21809                base_version: None,
21810            }])
21811            .unwrap();
21812        // A full-row v1 upsert always carries the v1 columns, so the surviving
21813        // commit is a value-free delete (it encodes under any schema that still
21814        // has the table).
21815        let survivor = client
21816            .mutate(vec![Mutation::Delete {
21817                table: "tasks".into(),
21818                row_id: "gone".into(),
21819                base_version: None,
21820            }])
21821            .unwrap();
21822        client
21823            .recreate_with_schema(&v2)
21824            .expect("the reset preserves the outbox");
21825        assert_eq!(
21826            client.pending_commit_ids(),
21827            vec![dropped.clone(), survivor.clone()],
21828            "the reset preserves both commits"
21829        );
21830        assert!(client.rejections.is_empty(), "the reset classifies nothing");
21831        client.drain_change_batches();
21832        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
21833        assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
21834
21835        // A fault anywhere in the atomic drop leaves the replica, the outbox,
21836        // the durable outcomes, the visible projection, the revision, and the
21837        // publish surface untouched. Clearing `syncNeeded` first keeps the
21838        // round's own status flip out of the assertion, so any published change
21839        // can only be the drop's.
21840        for fault in [
21841            "outcome-insert",
21842            "outbox-delete",
21843            "overlay-rebuild",
21844            "publish-revision",
21845        ] {
21846            client.set_sync_needed(false, false);
21847            client.drain_change_batches();
21848            let revision = client.local_revision();
21849            let trigger = match fault {
21850                "outcome-insert" => {
21851                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21852                }
21853                "outbox-delete" => {
21854                    "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21855                }
21856                // The optimized replay removes only the dropped projection, so
21857                // the replay fault belongs on the DELETE; a publish fault lands
21858                // on the revision write that `finish_observation` performs after
21859                // every durable effect already succeeded.
21860                "publish-revision" => {
21861                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21862                }
21863                _ => {
21864                    "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21865                }
21866            };
21867            client.conn.execute_batch(trigger).unwrap();
21868            let outcome = client
21869                .prepare_sync_round(false)
21870                .err()
21871                .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
21872            assert!(
21873                matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
21874                "{fault}: {outcome:?}"
21875            );
21876            client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
21877            assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
21878            assert_eq!(
21879                client.pending_commit_ids(),
21880                vec![dropped.clone(), survivor.clone()],
21881                "{fault}: the outbox is restored"
21882            );
21883            assert!(
21884                client.rejections.is_empty(),
21885                "{fault}: no in-memory rejection survives"
21886            );
21887            assert!(
21888                client.commit_outcome(&dropped).unwrap().is_none(),
21889                "{fault}: the durable outcome rolled back"
21890            );
21891            assert_eq!(client.local_revision(), revision, "{fault}: revision");
21892            assert_eq!(
21893                client.query("SELECT * FROM tasks", &[]).unwrap(),
21894                rows,
21895                "{fault}: the visible projection is unchanged"
21896            );
21897            assert!(
21898                client.drain_change_batches().is_empty(),
21899                "{fault}: nothing is published"
21900            );
21901        }
21902
21903        // A nested failure leaves the caller's transaction and earlier writes
21904        // intact; only the drop's savepoint is rolled back.
21905        client
21906            .conn
21907            .execute_batch(
21908                "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
21909             CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
21910             BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
21911            )
21912            .unwrap();
21913        assert!(client.drop_incompatible_outbox().is_err());
21914        assert!(!client.conn.is_autocommit());
21915        assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
21916        assert_eq!(
21917            client.pending_commit_ids(),
21918            vec![dropped.clone(), survivor.clone()]
21919        );
21920        assert!(client.commit_outcome(&dropped).unwrap().is_none());
21921        client
21922            .conn
21923            .execute_batch("DROP TRIGGER fail_drop; COMMIT")
21924            .unwrap();
21925
21926        // The successful drop publishes exactly once and leaves the surviving
21927        // intent queued for the same round. A log epoch is required before a
21928        // round builds push frames at all, so the classification provably runs
21929        // before any operation of the dropped commit is sent.
21930        client.set_meta(LOG_EPOCH_KEY, "epoch");
21931        client.set_sync_needed(false, false);
21932        client.drain_change_batches();
21933        let revision = client.local_revision();
21934        let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
21935        assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
21936        assert_eq!(
21937            prepared.meta.pushed_ids,
21938            vec![survivor.clone()],
21939            "the incompatible commit is classified before any operation is sent"
21940        );
21941        let rejection = client
21942            .rejections
21943            .iter()
21944            .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
21945            .expect("a sync.outbox_incompatible rejection");
21946        assert_eq!(rejection.client_commit_id, dropped);
21947        assert!(
21948            !rejection.retryable,
21949            "a schema-incompatible commit is final"
21950        );
21951        assert_eq!(
21952            client
21953                .commit_outcome(&dropped)
21954                .unwrap()
21955                .expect("a durable rejected outcome")
21956                .status,
21957            CommitOutcomeStatus::Rejected
21958        );
21959        assert_eq!(client.local_revision(), revision + 1, "one revision");
21960        let batches = client.drain_change_batches();
21961        assert_eq!(batches.len(), 1, "one change batch");
21962        assert!(
21963            batches[0].rejections_changed
21964                && batches[0].outcomes_changed
21965                && batches[0].status.is_some(),
21966            "{:?}",
21967            batches[0]
21968        );
21969        assert!(
21970            batches[0].tables.iter().any(|entry| entry.table == "tasks"),
21971            "{:?}",
21972            batches[0]
21973        );
21974        let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
21975        assert!(
21976            visible.is_empty(),
21977            "the dropped projection is undone: {visible:?}"
21978        );
21979
21980        // The observation's outer RELEASE (transaction COMMIT) is guarded too:
21981        // a rollback-journal reader holding SHARED makes it fail with the typed
21982        // busy classification, and releasing the reader lets the retry succeed.
21983        let path = temp_replica("outbox-drop-commit");
21984        let path_str = path.to_str().expect("path");
21985        let conn = Connection::open(path_str).expect("replica connection");
21986        let mut client =
21987            SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
21988                .expect("v1 replica");
21989        let dropped = client
21990            .mutate(vec![Mutation::Upsert {
21991                table: "tasks".into(),
21992                values: Map::from_iter([
21993                    ("id".into(), json!("dropped")),
21994                    ("note".into(), json!("gone")),
21995                ]),
21996                base_version: None,
21997            }])
21998            .unwrap();
21999        client
22000            .recreate_with_schema(&v2)
22001            .expect("the reset preserves the outbox");
22002        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22003        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22004        assert_eq!(rows.len(), 1, "the optimistic row is visible");
22005        client.set_sync_needed(false, false);
22006        client.drain_change_batches();
22007        let revision = client.local_revision();
22008        let reader = Connection::open(path_str).expect("reader");
22009        reader
22010            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22011            .expect("reader snapshot");
22012        client
22013            .conn
22014            .busy_timeout(std::time::Duration::ZERO)
22015            .expect("busy timeout");
22016        let outcome = client
22017            .prepare_sync_round(false)
22018            .err()
22019            .expect("a blocked COMMIT must fail the round");
22020        assert!(
22021            matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
22022            "{outcome:?}"
22023        );
22024        assert!(client.conn.is_autocommit(), "the savepoint is released");
22025        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22026        assert!(client.rejections.is_empty());
22027        assert!(client.commit_outcome(&dropped).unwrap().is_none());
22028        assert_eq!(client.local_revision(), revision);
22029        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
22030        assert!(client.drain_change_batches().is_empty());
22031        // The same round succeeds once the reader releases.
22032        drop(reader);
22033        client
22034            .conn
22035            .busy_timeout(std::time::Duration::from_secs(1))
22036            .expect("busy timeout");
22037        client
22038            .prepare_sync_round(false)
22039            .expect("the drop succeeds after the reader releases");
22040        assert!(client.pending_commit_ids().is_empty());
22041        assert!(client
22042            .rejections
22043            .iter()
22044            .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
22045        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
22046        drop(client);
22047        std::fs::remove_file(path).expect("remove replica");
22048    }
22049
22050    #[test]
22051    fn previous_version_read_reasons_come_from_live_client_state() {
22052        use crate::previous_version::{
22053            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
22054        };
22055        let path = std::env::temp_dir()
22056            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
22057            .to_string_lossy()
22058            .into_owned();
22059        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
22060        {
22061            let mut setup = SyncClient::open_path(
22062                "wiring".to_owned(),
22063                &previous_version_schema(1),
22064                previous_version_limits(),
22065                &path,
22066            )
22067            .expect("v1 install");
22068            setup
22069                .mutate(vec![Mutation::Upsert {
22070                    table: "tasks".to_owned(),
22071                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22072                    base_version: None,
22073                }])
22074                .expect("seed a row");
22075        }
22076        let mut client = SyncClient::open_path(
22077            "wiring".to_owned(),
22078            &previous_version_schema(2),
22079            previous_version_limits(),
22080            &path,
22081        )
22082        .expect("v2 bump captures");
22083        assert!(std::path::Path::new(&container).exists());
22084        let spec = PreviousVersionReadSpec {
22085            table: "tasks".to_owned(),
22086            ..Default::default()
22087        };
22088
22089        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
22090        recapture_previous_version(&client);
22091        client.lease_state = Some(LeaseState {
22092            lease_id: None,
22093            expires_at_ms: None,
22094            error_code: Some("lease.expired".to_owned()),
22095        });
22096        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22097        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22098        assert!(!snapshot.available);
22099        assert!(!std::path::Path::new(&container).exists());
22100
22101        // An expired lease uses the client clock (injected, no sleeps).
22102        recapture_previous_version(&client);
22103        client.set_now_ms(1_000);
22104        client.lease_state = Some(LeaseState {
22105            lease_id: Some("lease".to_owned()),
22106            expires_at_ms: Some(1_000),
22107            error_code: None,
22108        });
22109        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22110        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22111        assert!(!std::path::Path::new(&container).exists());
22112        client.lease_state = None;
22113
22114        // A revoked subscription ⇒ scope-revoked.
22115        recapture_previous_version(&client);
22116        client.subs.push(subscription(SubState::Revoked, 0, None));
22117        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22118        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
22119        assert!(!std::path::Path::new(&container).exists());
22120        client.subs.clear();
22121
22122        // Coverage completion: every ACTIVE subscription has a cursor and no
22123        // resume token — a still-resuming subscription is not completion.
22124        recapture_previous_version(&client);
22125        client
22126            .subs
22127            .push(subscription(SubState::Active, 0, Some("resume")));
22128        assert!(!client.previous_version_coverage_complete());
22129        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22130        assert!(snapshot.available);
22131        assert_eq!(snapshot.previous_version, Some(2));
22132        assert_eq!(snapshot.rows.len(), 1);
22133
22134        recapture_previous_version(&client);
22135        client.subs[0].bootstrap_state = None;
22136        assert!(client.previous_version_coverage_complete());
22137        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22138        assert_eq!(
22139            snapshot.reason,
22140            Some(PreviousVersionReason::CoverageComplete)
22141        );
22142        assert!(!std::path::Path::new(&container).exists());
22143        client.subs.clear();
22144
22145        // With no active subscription at all, coverage is NOT complete.
22146        assert!(!client.previous_version_coverage_complete());
22147
22148        drop(client);
22149        let _ = std::fs::remove_file(&path);
22150    }
22151
22152    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
22153    /// come from live state; this proves the OTHER caller —
22154    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
22155    /// actually removes the container for lease-inactivity, coverage
22156    /// completion and scope revocation, and leaves it alone while the
22157    /// replacement bootstrap is still resuming.
22158    #[test]
22159    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
22160        let path = std::env::temp_dir()
22161            .join(format!(
22162                "syncular-prev-lifetime-{}.db",
22163                uuid::Uuid::new_v4()
22164            ))
22165            .to_string_lossy()
22166            .into_owned();
22167        let container = format!(
22168            "{path}{}",
22169            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
22170        );
22171        let exists = || std::path::Path::new(&container).exists();
22172        {
22173            let mut setup = SyncClient::open_path(
22174                "lifetime".to_owned(),
22175                &previous_version_schema(1),
22176                previous_version_limits(),
22177                &path,
22178            )
22179            .expect("v1 install");
22180            setup
22181                .mutate(vec![Mutation::Upsert {
22182                    table: "tasks".to_owned(),
22183                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22184                    base_version: None,
22185                }])
22186                .expect("seed a row");
22187        }
22188        let mut client = SyncClient::open_path(
22189            "lifetime".to_owned(),
22190            &previous_version_schema(2),
22191            previous_version_limits(),
22192            &path,
22193        )
22194        .expect("v2 bump captures");
22195        // Pin the clock so the 24h TTL never fires in this test (the TTL has
22196        // its own coverage); the capture record's createdAtMs is 1.
22197        client.set_now_ms(1_000);
22198        assert!(exists());
22199
22200        // §7.3.5 lease error ⇒ the lifetime check drops the container.
22201        client.lease_state = Some(LeaseState {
22202            lease_id: None,
22203            expires_at_ms: None,
22204            error_code: Some("lease.expired".to_owned()),
22205        });
22206        client.previous_version_lifetime_check();
22207        assert!(!exists());
22208        client.lease_state = None;
22209
22210        // §7.4.5 coverage completion (every active sub has a cursor and no
22211        // resume token) ⇒ drop.
22212        recapture_previous_version(&client);
22213        client.subs.push(subscription(SubState::Active, 0, None));
22214        client.previous_version_lifetime_check();
22215        assert!(!exists());
22216        client.subs.clear();
22217
22218        // §3.3 scope revocation ⇒ drop.
22219        recapture_previous_version(&client);
22220        client.subs.push(subscription(SubState::Revoked, 0, None));
22221        client.previous_version_lifetime_check();
22222        assert!(!exists());
22223        client.subs.clear();
22224
22225        // Negative control: a still-resuming active subscription is NOT
22226        // coverage completion, so the container survives the same call.
22227        recapture_previous_version(&client);
22228        client
22229            .subs
22230            .push(subscription(SubState::Active, 0, Some("resume")));
22231        client.previous_version_lifetime_check();
22232        assert!(exists());
22233        client.subs.clear();
22234
22235        drop(client);
22236        let _ = std::fs::remove_file(&path);
22237        let _ = std::fs::remove_file(&container);
22238        let _ = std::fs::remove_file(format!("{container}-wal"));
22239        let _ = std::fs::remove_file(format!("{container}-shm"));
22240    }
22241}
22242
22243#[cfg(test)]
22244mod scope_move_delivery_tests {
22245    use super::*;
22246    use crate::native_transport::HostTransport;
22247    use crate::values::encode_row_json;
22248    use serde_json::json;
22249
22250    fn client() -> SyncClient {
22251        SyncClient::new(
22252            "scope-move".to_owned(),
22253            &json!({
22254                "version": 1,
22255                "tables": [{
22256                    "name": "tasks",
22257                    "primaryKey": "id",
22258                    "columns": [
22259                        { "name": "id", "type": "string", "nullable": false },
22260                        { "name": "project_id", "type": "string", "nullable": false }
22261                    ],
22262                    "scopes": [{ "pattern": "project:{project_id}" }]
22263                }]
22264            }),
22265            ClientLimits::default(),
22266        )
22267        .expect("test client")
22268    }
22269
22270    fn upsert(client: &SyncClient, project: &str) -> ssp2::model::Change {
22271        ssp2::model::Change {
22272            table_index: 0,
22273            row_id: "t1".to_owned(),
22274            op: Op::Upsert,
22275            row_version: Some(1),
22276            scopes: vec![("project_id".to_owned(), project.to_owned())],
22277            row: Some(
22278                encode_row_json(
22279                    client.schema.table("tasks").unwrap(),
22280                    "t1",
22281                    &Map::from_iter([
22282                        ("id".to_owned(), Value::from("t1")),
22283                        ("project_id".to_owned(), Value::from(project)),
22284                    ]),
22285                    &client.encryption,
22286                )
22287                .unwrap(),
22288            ),
22289        }
22290    }
22291
22292    fn delete(project: &str) -> ssp2::model::Change {
22293        ssp2::model::Change {
22294            table_index: 0,
22295            row_id: "t1".to_owned(),
22296            op: Op::Delete,
22297            row_version: None,
22298            scopes: vec![("project_id".to_owned(), project.to_owned())],
22299            row: None,
22300        }
22301    }
22302
22303    #[test]
22304    fn a_scope_move_keeps_the_row_in_either_section_order() {
22305        for order in [["old", "new"], ["new", "old"]] {
22306            let mut client = client();
22307            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22308            client
22309                .subscribe(
22310                    "old".into(),
22311                    "tasks".into(),
22312                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
22313                    None,
22314                )
22315                .unwrap();
22316            client
22317                .subscribe(
22318                    "new".into(),
22319                    "tasks".into(),
22320                    vec![("project_id".to_owned(), vec!["p2".to_owned()])],
22321                    None,
22322                )
22323                .unwrap();
22324            let seed = upsert(&client, "p1");
22325            client
22326                .apply_commit_frame(&["tasks".into()], &[seed], Some(1))
22327                .unwrap();
22328            assert_eq!(
22329                client.query("SELECT project_id FROM tasks", &[]).unwrap()[0]["project_id"],
22330                json!("p1")
22331            );
22332
22333            let mut frames = vec![Frame::RespHeader {
22334                required_schema_version: None,
22335                latest_schema_version: None,
22336                log_epoch: Some("epoch-1".to_owned()),
22337                reset_required: Some(false),
22338            }];
22339            for id in order {
22340                let project = if id == "old" { "p1" } else { "p2" };
22341                frames.push(Frame::SubStart {
22342                    id: id.to_owned(),
22343                    status: SubStatus::Active,
22344                    reason_code: String::new(),
22345                    effective_scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
22346                    bootstrap: false,
22347                });
22348                frames.push(Frame::Commit {
22349                    commit_seq: 2,
22350                    created_at_ms: 0,
22351                    actor_id: "actor".to_owned(),
22352                    tables: vec!["tasks".to_owned()],
22353                    changes: vec![if id == "old" {
22354                        delete("p1")
22355                    } else {
22356                        upsert(&client, "p2")
22357                    }],
22358                });
22359                frames.push(Frame::SubEnd {
22360                    next_cursor: 2,
22361                    bootstrap_state: None,
22362                });
22363            }
22364            let (_, meta) = client.build_request(false).unwrap();
22365            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
22366            let outcome = client.process_response(
22367                &mut transport,
22368                Message {
22369                    wire_version: WIRE_VERSION,
22370                    msg_kind: MsgKind::Response,
22371                    frames,
22372                },
22373                &meta,
22374            );
22375            assert!(
22376                matches!(outcome, SyncOutcome::Ok(_)),
22377                "{order:?}: {outcome:?}"
22378            );
22379            let rows = client
22380                .query("SELECT id, project_id FROM tasks", &[])
22381                .unwrap();
22382            assert_eq!(rows.len(), 1, "{order:?}: the row was lost");
22383            assert_eq!(rows[0]["project_id"], json!("p2"), "{order:?}");
22384        }
22385    }
22386
22387    struct RefusalTransport;
22388
22389    impl Transport for RefusalTransport {
22390        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
22391            let mut error = TransportError::new(
22392                "sync.invalid_client_id",
22393                "clientId is bound to a different actor in this partition",
22394            );
22395            error.details = Some(json!({
22396                "causeKind": "status",
22397                "httpStatus": 400,
22398                "category": "invalid-request",
22399                "retryable": false,
22400                "recommendedAction": "resetClientId"
22401            }));
22402            Err(error)
22403        }
22404
22405        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
22406            self.sync(request)
22407        }
22408
22409        fn download_segment(
22410            &mut self,
22411            _request: &SegmentRequest,
22412            _on_progress: &mut dyn FnMut(u64),
22413        ) -> Result<Vec<u8>, TransportError> {
22414            Err(TransportError::new("sync.transport_failed", "offline"))
22415        }
22416
22417        fn realtime_connect(&mut self) -> Result<(), TransportError> {
22418            Ok(())
22419        }
22420
22421        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
22422            Ok(())
22423        }
22424
22425        fn realtime_close(&mut self) -> Result<(), TransportError> {
22426            Ok(())
22427        }
22428    }
22429
22430    #[test]
22431    fn an_http_refusal_carries_its_code_and_recommended_action() {
22432        let mut client = client();
22433        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22434        let outcome = client.sync(&mut RefusalTransport);
22435        let SyncOutcome::Failed {
22436            error_code,
22437            recommended_action,
22438            details,
22439            ..
22440        } = outcome
22441        else {
22442            panic!("expected a failed round");
22443        };
22444        assert_eq!(error_code, "sync.invalid_client_id");
22445        assert_eq!(recommended_action.as_deref(), Some("resetClientId"));
22446        assert_eq!(
22447            details
22448                .as_ref()
22449                .and_then(|details| details.get("httpStatus")),
22450            Some(&json!(400))
22451        );
22452    }
22453
22454    #[test]
22455    fn reset_client_id_persists_and_changes_the_request_identity() {
22456        let mut client = client();
22457        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22458        let previous = client.client_id().to_owned();
22459        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
22460        let next = client.reset_client_id(&mut transport);
22461        assert_ne!(next, previous);
22462        assert_eq!(client.client_id(), next);
22463        assert_eq!(client.get_meta(CLIENT_ID_KEY), Some(next.clone()));
22464
22465        client
22466            .subscribe(
22467                "tasks".into(),
22468                "tasks".into(),
22469                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
22470                None,
22471            )
22472            .unwrap();
22473        let (message, _) = client.build_request(false).unwrap();
22474        assert!(matches!(
22475            message.frames.first(),
22476            Some(Frame::ReqHeader { client_id, .. }) if client_id == &next
22477        ));
22478    }
22479}