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 std::cell::{Cell, RefCell};
12use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
13
14use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
15use rusqlite::{Connection, OpenFlags, OptionalExtension};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use sha2::{Digest, Sha256};
19use ssp2::decode::WIRE_VERSION;
20use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
21use ssp2::primitives::RawJson;
22use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
23use ssp2::{
24    decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
25    PresenceKind,
26};
27
28use crate::api::{
29    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31    ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
32    CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
33    CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
34    DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
35    FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
36    LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
37    PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
38    RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RowState, SchemaFloor,
39    SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
40    WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
41    CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
42    MAX_DIAGNOSTIC_QUERY_FAILURES,
43};
44use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
45#[cfg(feature = "bench-internals")]
46use crate::bench::{Phase, Recorder};
47use crate::previous_version::{
48    capture_previous_version_from_replica, first_incompatibility,
49    reconcile_previous_version_at_boot, set_local_schema_descriptor,
50    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
51    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
52};
53use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
54use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
55use crate::values::{
56    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
57    encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
58    normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
59};
60
61/// §4.2 default: rows and SQLite images, both applied in committed chunks.
62/// Bit 3 (signed URLs, §5.4) is
63/// added per transport capability at request-build time.
64const DEFAULT_ACCEPT: u8 = 0b0111;
65const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
66const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
67const ACCEPT_SQLITE: u8 = 1 << 2;
68const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
69const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
70
71/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
72pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
73const LOCAL_REVISION_KEY: &str = "localRevision";
74const CLIENT_ID_KEY: &str = "clientId";
75const LEASE_STATE_KEY: &str = "leaseState";
76const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
77const LOG_EPOCH_KEY: &str = "logEpoch";
78/// Persisted fail-closed quarantine marker: present while a security preflight
79/// is pending and `activateSecurity` has yet to run. Storing it in the replica
80/// keeps the gate with the data it protects, so a rebuilt host handle reopening
81/// the same file returns in preflight even though its in-memory state is fresh.
82const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
83const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
84const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
85/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
86/// the new schema after a bump. Never a wire code (§10.3).
87const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
88/// §5.11 client-local code: an encode at the push seam resolved no usable key
89/// id or named an unknown key. Never a wire code (§10.3).
90const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
91
92#[derive(Debug, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94struct PersistedLocalDataRebootstrapReceipt {
95    version: u8,
96    retained_commits: u64,
97    reset_subscriptions: u64,
98}
99
100fn invalid_local_rebootstrap_receipt() -> String {
101    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
102}
103
104fn encode_local_rebootstrap_receipt(
105    retained_commits: usize,
106    reset_subscriptions: usize,
107) -> Result<String, String> {
108    let retained_commits =
109        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
110    let reset_subscriptions =
111        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
112    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
113        return Err(invalid_local_rebootstrap_receipt());
114    }
115    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
116        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
117        retained_commits,
118        reset_subscriptions,
119    })
120    .map_err(|_| invalid_local_rebootstrap_receipt())
121}
122
123fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
124    // Pre-0.15.36 markers proved application but did not retain the receipt.
125    if value == "v1" {
126        return Ok((0, 0));
127    }
128    let receipt: PersistedLocalDataRebootstrapReceipt =
129        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
130    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
131        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
132        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
133    {
134        return Err(invalid_local_rebootstrap_receipt());
135    }
136    Ok((
137        usize::try_from(receipt.retained_commits)
138            .map_err(|_| invalid_local_rebootstrap_receipt())?,
139        usize::try_from(receipt.reset_subscriptions)
140            .map_err(|_| invalid_local_rebootstrap_receipt())?,
141    ))
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum SubState {
146    Active,
147    Revoked,
148    Failed,
149}
150
151#[cfg(test)]
152mod observation_tests {
153    use super::*;
154    use crate::native_transport::HostTransport;
155    use crate::values::encode_row_json;
156    use serde_json::json;
157
158    fn client() -> SyncClient {
159        SyncClient::new(
160            "retry-test".to_owned(),
161            &json!({
162                "version": 1,
163                "tables": [{
164                    "name": "tasks",
165                    "primaryKey": "id",
166                    "columns": [
167                        { "name": "id", "type": "string", "nullable": false },
168                        { "name": "project_id", "type": "string", "nullable": false }
169                    ],
170                    "scopes": [{ "pattern": "project:{project_id}" }]
171                }]
172            }),
173            ClientLimits::default(),
174        )
175        .expect("test client")
176    }
177
178    #[cfg(feature = "e2ee")]
179    #[test]
180    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
181        let schema = json!({
182            "version": 1,
183            "tables": [{
184                "name": "secrets",
185                "primaryKey": "id",
186                "columns": [
187                    { "name": "id", "type": "string", "nullable": false },
188                    { "name": "encryption_key_id", "type": "string", "nullable": true },
189                    { "name": "note", "type": "bytes", "nullable": true,
190                      "encrypted": true, "declaredType": "string" }
191                ],
192                "scopes": []
193            }]
194        });
195        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
196            .expect("test client");
197        let mut config = crate::values::EncryptionConfig::default();
198        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
199        config
200            .key_id_columns
201            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
202        client.set_encryption(config);
203        // A patch on a locally absent row presents an encrypted column with no
204        // stored key id to fall back to. Authoring succeeds...
205        let commit_id = client
206            .patch(
207                "secrets",
208                "ghost",
209                Map::from_iter([("note".to_owned(), json!("x"))]),
210                None,
211            )
212            .expect("patch records without an author-time encrypt failure");
213        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
214        // ...and the push seam raises a durable local rejection, not a throw.
215        assert!(client.drop_unencodable_outbox().unwrap());
216        assert!(client.pending_commit_ids().is_empty());
217        let rejections = client.rejections();
218        assert_eq!(rejections.len(), 1);
219        assert_eq!(rejections[0].code, "client.encrypt_failed");
220        assert_eq!(rejections[0].client_commit_id, commit_id);
221        assert_eq!(
222            client
223                .commit_outcome(&commit_id)
224                .unwrap()
225                .map(|outcome| outcome.status),
226            Some(CommitOutcomeStatus::Rejected)
227        );
228    }
229
230    #[test]
231    fn patch_with_a_scope_column_matches_the_stored_local_row() {
232        let mut client = client();
233        client.create_synced_tables().unwrap();
234        client
235            .conn
236            .execute(
237                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
238                [],
239            )
240            .unwrap();
241
242        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
243        // the scope column leaves the presence set.
244        client
245            .patch(
246                "tasks",
247                "t1",
248                Map::from_iter([
249                    ("project_id".to_owned(), json!("p1")),
250                    ("id".to_owned(), json!("t1")),
251                ]),
252                None,
253            )
254            .expect("an equal scope column is a no-op");
255        let values = client.outbox[0].ops[0]
256            .values
257            .clone()
258            .expect("the patch records values");
259        assert_eq!(values.get("id"), Some(&json!("t1")));
260        assert!(!values.contains_key("project_id"));
261
262        // A differing value stays rejected.
263        let differing = client.patch(
264            "tasks",
265            "t1",
266            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
267            None,
268        );
269        assert!(differing
270            .expect_err("a differing scope column is rejected")
271            .contains("patch cannot write scope column"));
272
273        // An absent local row leaves nothing to prove equality against, so
274        // the patch fails closed.
275        let absent = client.patch(
276            "tasks",
277            "ghost",
278            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
279            None,
280        );
281        assert!(absent
282            .expect_err("an absent local row is rejected")
283            .contains("patch cannot write scope column"));
284        assert_eq!(client.pending_commit_ids().len(), 1);
285    }
286
287    #[test]
288    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
289        let schema = json!({
290            "version": 1,
291            "tables": [
292                {
293                    "name": "tenants",
294                    "primaryKey": "tenant_id",
295                    "columns": [
296                        { "name": "tenant_id", "type": "string", "nullable": false },
297                        { "name": "body", "type": "string", "nullable": false }
298                    ],
299                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
300                },
301                {
302                    "name": "buckets",
303                    "primaryKey": "id",
304                    "columns": [
305                        { "name": "id", "type": "string", "nullable": false },
306                        { "name": "bucket", "type": "float", "nullable": false },
307                        { "name": "body", "type": "string", "nullable": false }
308                    ],
309                    "scopes": [{ "pattern": "bucket:{bucket}" }]
310                }
311            ]
312        });
313        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
314            .expect("test client");
315        client.create_synced_tables().unwrap();
316
317        // A primary key that is also a scope column with no local row: the
318        // sparse payload's key is the row id being patched, so its value is
319        // proven equal without a stored-row read.
320        client
321            .patch(
322                "tenants",
323                "t1",
324                Map::from_iter([
325                    ("tenant_id".to_owned(), json!("t1")),
326                    ("body".to_owned(), json!("created")),
327                ]),
328                None,
329            )
330            .expect("the primary-key scope column is proven equal by construction");
331
332        // A stored REAL scope value coerces from a supplied integer.
333        client
334            .conn
335            .execute(
336                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
337                [],
338            )
339            .unwrap();
340        client
341            .patch(
342                "buckets",
343                "b1",
344                Map::from_iter([
345                    ("bucket".to_owned(), json!(2)),
346                    ("body".to_owned(), json!("coerced")),
347                ]),
348                None,
349            )
350            .expect("the stored REAL value coerces from a supplied integer");
351        let values = client.outbox[1].ops[0]
352            .values
353            .clone()
354            .expect("the patch records values");
355        assert!(!values.contains_key("bucket"));
356
357        // A differing value stays rejected.
358        let differing = client.patch(
359            "buckets",
360            "b1",
361            Map::from_iter([("bucket".to_owned(), json!(3))]),
362            None,
363        );
364        assert!(differing
365            .expect_err("a differing scope value is rejected")
366            .contains("patch cannot write scope column"));
367    }
368
369    #[cfg(feature = "e2ee")]
370    #[test]
371    fn stored_key_fallback_resolves_an_integer_primary_key() {
372        let schema = json!({
373            "version": 1,
374            "tables": [{
375                "name": "patients",
376                "primaryKey": "id",
377                "columns": [
378                    { "name": "id", "type": "integer", "nullable": false },
379                    { "name": "encryption_key_id", "type": "string", "nullable": true },
380                    { "name": "note", "type": "bytes", "nullable": true,
381                      "encrypted": true, "declaredType": "string" }
382                ],
383                "scopes": []
384            }]
385        });
386        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
387            .expect("test client");
388        client.create_synced_tables().unwrap();
389        client
390            .conn
391            .execute(
392                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
393                 VALUES (5, 'k1', NULL, 1)",
394                [],
395            )
396            .unwrap();
397        let mut config = crate::values::EncryptionConfig::default();
398        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
399        config
400            .key_id_columns
401            .insert("patients".to_owned(), "encryption_key_id".to_owned());
402        client.set_encryption(config);
403        let table = client.schema.table("patients").unwrap();
404        let fallback = client
405            .stored_key_fallback(table, "5")
406            .expect("the stored integer key resolves the fallback");
407        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
408    }
409
410    #[test]
411    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
412        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
413            {"name":"id","type":"string","nullable":false},
414            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
415        for state in ["absent", "cached", "pinned"] {
416            let path = std::env::temp_dir().join(format!(
417                "syncular-blob-stage-{}.sqlite",
418                uuid::Uuid::new_v4()
419            ));
420            let mut client = SyncClient::open_path(
421                "stage-test".into(),
422                &schema,
423                Default::default(),
424                path.to_str().unwrap(),
425            )
426            .unwrap();
427            let bytes = b"atomic stage";
428            if state != "absent" {
429                client
430                    .upload_blob(bytes, Some("text/plain".into()), None)
431                    .unwrap();
432                if state == "cached" {
433                    client
434                        .conn
435                        .execute("DELETE FROM _syncular_blob_uploads", [])
436                        .unwrap();
437                }
438            }
439            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
440            client
441                .conn
442                .execute_batch(
443                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
444                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
445                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
446                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
447                )
448                .unwrap();
449            let error = client
450                .upload_blob(bytes, Some("application/octet-stream".into()), None)
451                .unwrap_err();
452            assert!(error.contains("injected stage failure"), "{state}: {error}");
453            assert!(client.conn.is_autocommit());
454            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
455            drop(client);
456            let mut reopened = SyncClient::open_path(
457                "stage-test".into(),
458                &schema,
459                Default::default(),
460                path.to_str().unwrap(),
461            )
462            .unwrap();
463            let durable = reopened
464                .query("SELECT * FROM _syncular_blobs", &[])
465                .unwrap();
466            reopened
467                .conn
468                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
469                .unwrap();
470            let retried = reopened.upload_blob(bytes, None, None).unwrap();
471            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
472            assert_eq!(
473                reopened
474                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
475                    .unwrap(),
476                vec![Map::from_iter([(
477                    "blob_id".into(),
478                    Value::from(blob_id_for(bytes)),
479                )])]
480            );
481            drop(reopened);
482            std::fs::remove_file(&path).unwrap();
483            assert_eq!(durable, bodies, "{state}: reopened body state");
484            assert_eq!(immediate, bodies, "{state}: immediate body state");
485        }
486    }
487
488    #[test]
489    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
490        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
491            {"name":"id","type":"string","nullable":false},
492            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
493        for fault in [
494            "missing",
495            "body-type",
496            "length",
497            "hash",
498            "pin-metadata",
499            "read",
500            "pin-delete",
501        ] {
502            let path = std::env::temp_dir().join(format!(
503                "syncular-blob-upload-{}.sqlite",
504                uuid::Uuid::new_v4()
505            ));
506            let mut client = SyncClient::open_path(
507                "upload-test".into(),
508                &schema,
509                Default::default(),
510                path.to_str().unwrap(),
511            )
512            .unwrap();
513            let bytes = b"durable upload";
514            let ref_value = client
515                .upload_blob(bytes, Some("text/plain".into()), None)
516                .unwrap();
517            let commit = client
518                .mutate(vec![Mutation::Upsert {
519                    table: "attachments".into(),
520                    values: Map::from_iter([
521                        ("id".into(), Value::from("pending")),
522                        (
523                            "file".into(),
524                            Value::from(format!(
525                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
526                                blob_id_for(bytes),
527                                bytes.len()
528                            )),
529                        ),
530                    ]),
531                    base_version: None,
532                }])
533                .unwrap();
534            client
535                .conn
536                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
537                .unwrap();
538            client.conn.execute_batch(match fault {
539                "missing" => "DELETE FROM _syncular_blobs",
540                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
541                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
542                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
543                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
544                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
545                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
546            }).unwrap();
547            let state = if fault == "read" {
548                Vec::new()
549            } else {
550                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
551            };
552            let mut transport = CountingRealtimeTransport::default();
553            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
554            assert_eq!(
555                error.code,
556                if matches!(fault, "read" | "pin-delete") {
557                    "client.failed"
558                } else {
559                    "sync.local_corrupt"
560                },
561                "{fault}"
562            );
563            assert_eq!(
564                transport.blob_uploads.len(),
565                usize::from(fault == "pin-delete"),
566                "{fault}: corrupt bytes must never be uploaded"
567            );
568            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
569            if fault != "read" {
570                assert_eq!(
571                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
572                    state
573                );
574            } else {
575                client
576                    .conn
577                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
578                    .unwrap();
579            }
580            drop(client);
581            let mut reopened = SyncClient::open_path(
582                "upload-test".into(),
583                &schema,
584                Default::default(),
585                path.to_str().unwrap(),
586            )
587            .unwrap();
588            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
589            if fault != "read" {
590                assert_eq!(
591                    reopened
592                        .query("SELECT * FROM _syncular_blobs", &[])
593                        .unwrap(),
594                    state
595                );
596            }
597            if fault == "pin-delete" {
598                reopened
599                    .conn
600                    .execute_batch("DROP TRIGGER fail_pin_delete")
601                    .unwrap();
602            }
603            if fault == "pin-metadata" {
604                reopened
605                    .conn
606                    .execute(
607                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
608                        [],
609                    )
610                    .unwrap();
611            }
612            reopened
613                .conn
614                .execute_batch(
615                    "DELETE FROM _syncular_blobs;
616                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
617                )
618                .unwrap();
619            transport.blob_uploads.clear();
620            reopened.flush_blob_uploads(&mut transport).unwrap();
621            assert_eq!(
622                transport.blob_uploads,
623                [(blob_id_for(bytes), bytes.to_vec())]
624            );
625            assert_eq!(
626                reopened
627                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
628                    .unwrap(),
629                Vec::<Map<String, Value>>::new()
630            );
631            assert_eq!(reopened.pending_commit_ids(), [commit]);
632            assert_eq!(
633                reopened
634                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
635                    .unwrap()
636                    .unwrap()
637                    .bytes,
638                bytes
639            );
640            drop(reopened);
641            std::fs::remove_file(path).unwrap();
642        }
643    }
644
645    #[test]
646    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
647        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
648            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
649            "scopes":[{"pattern":"project:{project_id}"}]}]});
650        for realtime in [false, true] {
651            for fault in [
652                "none",
653                "row",
654                "revision",
655                "commit",
656                "cursor",
657                "error",
658                "missing-end",
659            ] {
660                if realtime && fault == "missing-end" {
661                    continue;
662                }
663                let path = std::env::temp_dir()
664                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
665                let mut client = SyncClient::open_path(
666                    "frame-test".into(),
667                    &schema,
668                    Default::default(),
669                    path.to_str().unwrap(),
670                )
671                .unwrap();
672                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
673                client
674                    .subscribe(
675                        "tasks".into(),
676                        "tasks".into(),
677                        vec![("project_id".into(), vec!["p1".into()])],
678                        None,
679                    )
680                    .unwrap();
681                client.subs[0].cursor = 0;
682                client.subs[0].synced_once = true;
683                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
684                client.persist_sub(&client.subs[0]).unwrap();
685                client.realtime_state = RealtimeState::Connected;
686                client.realtime_reason_code = None;
687                client.drain_change_batches();
688                let mut transport = CountingRealtimeTransport::default();
689                let (_, meta) = client.build_request(false);
690                let mut response = Message {
691                    wire_version: WIRE_VERSION,
692                    msg_kind: MsgKind::Response,
693                    frames: vec![
694                        Frame::RespHeader {
695                            required_schema_version: None,
696                            latest_schema_version: None,
697                            log_epoch: Some("epoch-1".into()),
698                            reset_required: Some(false),
699                        },
700                        Frame::SubStart {
701                            id: "tasks".into(),
702                            status: SubStatus::Active,
703                            reason_code: "".into(),
704                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
705                            bootstrap: false,
706                        },
707                    ],
708                };
709                for (index, id) in ["first", "second"].iter().enumerate() {
710                    let values = Map::from_iter([
711                        ("id".into(), Value::from(*id)),
712                        ("project_id".into(), Value::from("p1")),
713                    ]);
714                    response.frames.push(Frame::Commit {
715                        commit_seq: index as i64 + 1,
716                        actor_id: "writer".into(),
717                        created_at_ms: 1,
718                        tables: vec!["tasks".into()],
719                        changes: vec![ssp2::model::Change {
720                            table_index: 0,
721                            row_id: (*id).into(),
722                            op: Op::Upsert,
723                            row_version: Some(index as i64 + 1),
724                            scopes: vec![("project_id".into(), "p1".into())],
725                            row: Some(
726                                encode_row_json(
727                                    client.schema.table("tasks").unwrap(),
728                                    id,
729                                    &values,
730                                    &client.encryption,
731                                )
732                                .unwrap(),
733                            ),
734                        }],
735                    });
736                }
737                response.frames.push(Frame::SubEnd {
738                    next_cursor: 2,
739                    bootstrap_state: None,
740                });
741                let valid = response.clone();
742                match fault {
743                    "row" => {
744                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
745                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
746                    },
747                    "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(),
748                    "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(),
749                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
750                    "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});},
751                    "missing-end" => {response.frames.truncate(3);},
752                    _ => {}
753                }
754                if realtime {
755                    client.on_realtime_binary(&mut transport, &encode_message(&response));
756                } else {
757                    assert_eq!(
758                        matches!(
759                            client.process_response(&mut transport, response, &meta),
760                            SyncOutcome::Ok(_)
761                        ),
762                        fault == "none",
763                        "{fault}"
764                    );
765                }
766                assert!(
767                    client.conn.is_autocommit(),
768                    "no open transaction after {realtime}/{fault}"
769                );
770                let count = if matches!(fault, "none" | "cursor") {
771                    2
772                } else {
773                    1
774                };
775                let expected_revision = count + u64::from(realtime && fault != "none");
776                assert_eq!(
777                    client.local_revision(),
778                    expected_revision,
779                    "{realtime}/{fault}"
780                );
781                let batches = client.drain_change_batches();
782                assert_eq!(
783                    batches.len(),
784                    expected_revision as usize,
785                    "{realtime}/{fault}"
786                );
787                let row_batches: Vec<_> = batches
788                    .iter()
789                    .filter(|batch| !batch.tables.is_empty())
790                    .collect();
791                assert_eq!(row_batches.len(), count as usize);
792                assert_eq!(
793                    row_batches
794                        .iter()
795                        .map(|b| b.revision.clone())
796                        .collect::<Vec<_>>(),
797                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
798                );
799                for table in ["tasks", "_syncular_base_tasks"] {
800                    assert_eq!(
801                        client
802                            .conn
803                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
804                                .get::<_, u64>(0))
805                            .unwrap(),
806                        count,
807                        "{realtime}/{fault}/{table}"
808                    );
809                }
810                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
811                assert_eq!(
812                    transport
813                        .messages
814                        .iter()
815                        .filter(|m| m.contains("\"ack\""))
816                        .count(),
817                    usize::from(fault == "none")
818                );
819                if matches!(fault, "revision" | "commit" | "cursor") {
820                    client
821                        .conn
822                        .execute_batch("DROP TRIGGER fail_frame")
823                        .unwrap();
824                }
825                drop(client);
826                let mut reopened = SyncClient::open_path(
827                    "frame-test".into(),
828                    &schema,
829                    Default::default(),
830                    path.to_str().unwrap(),
831                )
832                .unwrap();
833                assert_eq!(reopened.local_revision(), expected_revision);
834                assert_eq!(
835                    reopened
836                        .query("SELECT id FROM tasks ORDER BY id", &[])
837                        .unwrap()
838                        .len(),
839                    count as usize
840                );
841                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
842                let (_, meta) = reopened.build_request(false);
843                assert!(matches!(
844                    reopened.process_response(&mut transport, valid, &meta),
845                    SyncOutcome::Ok(_)
846                ));
847                assert_eq!(
848                    reopened
849                        .query("SELECT id FROM tasks ORDER BY id", &[])
850                        .unwrap()
851                        .len(),
852                    2
853                );
854                assert_eq!(reopened.subs[0].cursor, 2);
855                drop(reopened);
856                std::fs::remove_file(path).unwrap();
857            }
858        }
859    }
860
861    #[test]
862    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
863        for status in [SubStatus::Reset, SubStatus::Revoked] {
864            for fault in ["subscription", "revision"] {
865                let mut client = client();
866                client
867                    .subscribe(
868                        "tasks".into(),
869                        "tasks".into(),
870                        vec![("project_id".into(), vec!["p1".into()])],
871                        None,
872                    )
873                    .unwrap();
874                client.subs[0].cursor = 41;
875                client.subs[0].synced_once = true;
876                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
877                client.persist_sub(&client.subs[0]).unwrap();
878                client
879                    .mutate(vec![Mutation::Upsert {
880                        table: "tasks".into(),
881                        values: Map::from_iter([
882                            ("id".into(), Value::from("pending")),
883                            ("project_id".into(), Value::from("p1")),
884                        ]),
885                        base_version: None,
886                    }])
887                    .unwrap();
888                let ids = client.pending_commit_ids();
889                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
890                let revision = client.local_revision();
891                client.drain_change_batches();
892                let (_, meta) = client.build_request(false);
893                client.conn.execute_batch(if fault == "subscription" {
894                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
895                } else {
896                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
897                }).unwrap();
898                let mut transport = CountingRealtimeTransport::default();
899                let mut report = SyncReport::default();
900                // Reset changes window completeness and therefore needs a revision.
901                if status == SubStatus::Reset {
902                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
903                }
904                let result = client.process_section(
905                    &mut transport,
906                    "tasks",
907                    status,
908                    "sync.scope_revoked",
909                    vec![],
910                    vec![],
911                    Some((42, None)),
912                    &meta,
913                    &mut report,
914                );
915                assert!(result.is_err(), "{status:?}/{fault}");
916                assert!(client.conn.is_autocommit());
917                assert_eq!(client.subs[0].cursor, 41);
918                assert_eq!(client.subs[0].state, SubState::Active);
919                assert_eq!(client.pending_commit_ids(), ids);
920                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
921                assert!(client.rejections.is_empty());
922                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
923                assert_eq!(client.local_revision(), revision);
924                assert!(client.drain_change_batches().is_empty());
925                assert!(report.revoked.is_empty() && report.resets.is_empty());
926            }
927        }
928    }
929
930    #[test]
931    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
932        let mut client = client();
933        client
934            .subscribe(
935                "tasks".into(),
936                "tasks".into(),
937                vec![("project_id".into(), vec!["p1".into()])],
938                None,
939            )
940            .unwrap();
941        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
942        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
943            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
944        let row = |id: &str| ssp2::segment::SegmentRow {
945            server_version: 1,
946            values: vec![
947                Some(ColumnValue::String(id.into())),
948                Some(ColumnValue::String("p1".into())),
949            ],
950        };
951        let segment = RowsSegment {
952            table: "tasks".into(),
953            schema_version: 1,
954            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
955            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
956        };
957        assert!(client.apply_segment(0, &segment, true).is_err());
958        assert!(client.conn.is_autocommit());
959        assert_eq!(
960            client
961                .query("SELECT id FROM tasks ORDER BY id", &[])
962                .unwrap(),
963            vec![Map::from_iter([("id".into(), Value::from("first"))])]
964        );
965        assert_eq!(client.local_revision(), 1);
966        assert_eq!(client.drain_change_batches().len(), 1);
967        assert_eq!(client.subs[0].cursor, -1);
968        client
969            .conn
970            .execute_batch("DROP TRIGGER fail_block")
971            .unwrap();
972        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
973        assert_eq!(client.local_revision(), 3);
974        assert_eq!(client.drain_change_batches().len(), 2);
975    }
976
977    #[test]
978    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
979        assert_eq!(
980            client().diagnostics_storage().blob_cache_bytes_approx,
981            Some(0)
982        );
983        let client = SyncClient::new(
984            "diagnostic-storage".to_owned(),
985            &json!({
986                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
987                    "columns": [{"name": "id", "type": "string", "nullable": false},
988                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
989            }),
990            ClientLimits {
991                blob_cache_max_bytes: Some(5),
992                ..Default::default()
993            },
994        )
995        .unwrap();
996        let empty = client.diagnostics_storage();
997        assert_eq!(empty.status, "healthy");
998        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
999        assert_eq!(empty.retained_outcome_entries, Some(0));
1000        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1001        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1002        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1003            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1004                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1005            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1006                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1007        let written = client.diagnostics_storage();
1008        assert_eq!(written.status, "pressure");
1009        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1010        assert_eq!(written.retained_outcome_entries, Some(2));
1011        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1012        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1013        assert_eq!(
1014            written.pressure_reason_code.as_deref(),
1015            Some("client.blob_cache_over_limit")
1016        );
1017        client
1018            .conn
1019            .execute_batch(
1020                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1021            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1022            )
1023            .unwrap();
1024        let staged = client.diagnostics_storage();
1025        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1026        assert_eq!(staged.retained_outcome_entries, Some(0));
1027        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1028        assert_eq!(staged.status, "healthy");
1029        client.conn.execute_batch("ROLLBACK").unwrap();
1030        assert_eq!(client.diagnostics_storage(), written);
1031        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1032        let unreadable = client.diagnostics_storage();
1033        assert_eq!(unreadable.status, "unreadable");
1034        assert!(unreadable.database_bytes_approx.is_none());
1035        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1036        assert!(unreadable.retained_outcome_entries.is_none());
1037        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1038        assert!(unreadable.blob_cache_bytes_approx.is_none());
1039        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1040        let restored = client.diagnostics_storage();
1041        assert_eq!(restored.status, written.status);
1042        assert_eq!(
1043            restored.retained_outcome_bytes_approx,
1044            written.retained_outcome_bytes_approx
1045        );
1046        client.conn.set_prepared_statement_cache_capacity(0);
1047        assert_eq!(client.diagnostics_storage(), restored);
1048    }
1049
1050    /// Answers every round with one server `ERROR` frame (§1.6).
1051    struct ServerErrorTransport {
1052        code: &'static str,
1053        retryable: bool,
1054    }
1055
1056    impl Transport for ServerErrorTransport {
1057        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1058            Ok(encode_message(&Message {
1059                wire_version: WIRE_VERSION,
1060                msg_kind: MsgKind::Response,
1061                frames: vec![
1062                    Frame::RespHeader {
1063                        required_schema_version: None,
1064                        latest_schema_version: None,
1065                        log_epoch: Some("epoch-1".to_owned()),
1066                        reset_required: Some(false),
1067                    },
1068                    Frame::Error {
1069                        code: self.code.to_owned(),
1070                        message: "server error".to_owned(),
1071                        category: "internal".to_owned(),
1072                        retryable: self.retryable,
1073                        recommended_action: "retryLater".to_owned(),
1074                        details: None,
1075                    },
1076                ],
1077            }))
1078        }
1079
1080        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1081            self.sync(request)
1082        }
1083
1084        fn download_segment(
1085            &mut self,
1086            _request: &SegmentRequest,
1087            _on_progress: &mut dyn FnMut(u64),
1088        ) -> Result<Vec<u8>, TransportError> {
1089            Err(TransportError::new("sync.transport_failed", "offline"))
1090        }
1091
1092        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1093            Ok(())
1094        }
1095
1096        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1097            Ok(())
1098        }
1099
1100        fn realtime_close(&mut self) -> Result<(), TransportError> {
1101            Ok(())
1102        }
1103    }
1104
1105    #[test]
1106    fn retryable_server_error_schedules_a_background_retry() {
1107        let mut client = client();
1108        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1109        let mut transport = ServerErrorTransport {
1110            code: "sync.internal_error",
1111            retryable: true,
1112        };
1113        assert!(matches!(
1114            client.sync(&mut transport),
1115            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1116        ));
1117        assert!(matches!(
1118            client.drain_sync_intents().as_slice(),
1119            [SyncIntent::Background { delay_ms: 250 }]
1120        ));
1121        assert_eq!(
1122            client.progress().snapshot().unwrap().retry_delay_ms,
1123            Some(250)
1124        );
1125        let mut transport = ServerErrorTransport {
1126            code: "sync.invalid_request",
1127            retryable: false,
1128        };
1129        assert!(matches!(
1130            client.sync(&mut transport),
1131            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1132        ));
1133        assert!(client.drain_sync_intents().is_empty());
1134        let progress = client.progress().snapshot().unwrap();
1135        assert_eq!(progress.state, ProgressState::Failed);
1136        assert_eq!(progress.retry_delay_ms, None);
1137    }
1138
1139    #[test]
1140    fn failed_progress_retry_delay_doubles_to_the_cap() {
1141        let mut client = client();
1142        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1143        let mut transport = ServerErrorTransport {
1144            code: "sync.internal_error",
1145            retryable: true,
1146        };
1147        let delays: Vec<Option<u64>> = (0..9)
1148            .map(|_| {
1149                client.sync(&mut transport);
1150                client.progress().snapshot().unwrap().retry_delay_ms
1151            })
1152            .collect();
1153        assert_eq!(
1154            delays,
1155            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1156        );
1157    }
1158
1159    #[test]
1160    fn background_retry_deadlines_back_off_and_reset() {
1161        let mut client = client();
1162        client.schedule_background_retry();
1163        assert!(matches!(
1164            client.drain_sync_intents().as_slice(),
1165            [SyncIntent::Background { delay_ms: 250 }]
1166        ));
1167        client.schedule_background_retry();
1168        assert!(matches!(
1169            client.drain_sync_intents().as_slice(),
1170            [SyncIntent::Background { delay_ms: 500 }]
1171        ));
1172        client.reset_background_retry();
1173        client.schedule_background_retry();
1174        assert!(matches!(
1175            client.drain_sync_intents().as_slice(),
1176            [SyncIntent::Background { delay_ms: 250 }]
1177        ));
1178    }
1179
1180    #[test]
1181    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1182        let mut client = client();
1183        client
1184            .subscribe(
1185                "epoch-tasks".to_owned(),
1186                "tasks".to_owned(),
1187                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1188                None,
1189            )
1190            .expect("subscribe");
1191        client.drain_sync_intents();
1192
1193        client
1194            .run_log_epoch_reset("epoch-2")
1195            .expect("reset partition log epoch");
1196
1197        assert!(matches!(
1198            client.drain_sync_intents().as_slice(),
1199            [SyncIntent::Interactive]
1200        ));
1201    }
1202
1203    #[derive(Default)]
1204    struct CountingRealtimeTransport {
1205        connects: usize,
1206        closes: usize,
1207        messages: Vec<String>,
1208        blob_uploads: Vec<(String, Vec<u8>)>,
1209        blob_download: Option<Vec<u8>>,
1210        sync_calls: usize,
1211        realtime_sync_calls: usize,
1212        refuse_connect: bool,
1213    }
1214
1215    impl Transport for CountingRealtimeTransport {
1216        fn blob_upload(
1217            &mut self,
1218            blob_id: &str,
1219            bytes: &[u8],
1220            _media_type: Option<&str>,
1221        ) -> Result<(), TransportError> {
1222            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1223            Ok(())
1224        }
1225
1226        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1227            self.blob_download
1228                .clone()
1229                .map(BlobDownload::Bytes)
1230                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1231        }
1232
1233        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1234            self.sync_calls += 1;
1235            Err(TransportError::new("sync.transport_failed", "offline"))
1236        }
1237
1238        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1239            self.realtime_sync_calls += 1;
1240            Err(TransportError::new("sync.transport_failed", "offline"))
1241        }
1242
1243        fn download_segment(
1244            &mut self,
1245            _request: &SegmentRequest,
1246            _on_progress: &mut dyn FnMut(u64),
1247        ) -> Result<Vec<u8>, TransportError> {
1248            Err(TransportError::new("sync.transport_failed", "offline"))
1249        }
1250
1251        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1252            self.connects += 1;
1253            if self.refuse_connect {
1254                return Err(TransportError::new("transport.failed", "refused"));
1255            }
1256            Ok(())
1257        }
1258
1259        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1260            self.messages.push(text.to_owned());
1261            Ok(())
1262        }
1263
1264        fn realtime_close(&mut self) -> Result<(), TransportError> {
1265            self.closes += 1;
1266            Ok(())
1267        }
1268    }
1269
1270    #[test]
1271    fn blob_results_own_downloaded_bytes() {
1272        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1273            {"name":"id","type":"string","nullable":false},
1274            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1275        let bytes = b"owned typed result".to_vec();
1276        let blob_id = blob_id_for(&bytes);
1277        let mut transport = CountingRealtimeTransport {
1278            blob_download: Some(bytes.clone()),
1279            ..Default::default()
1280        };
1281        let result = {
1282            let mut client =
1283                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1284                    .unwrap();
1285            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1286            assert_eq!(result.blob_id, blob_id);
1287            assert_eq!(result.byte_length, bytes.len() as i64);
1288            assert_eq!(result.bytes, bytes);
1289            assert_eq!(result.media_type, None);
1290            assert_eq!(
1291                client
1292                    .conn
1293                    .query_row(
1294                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1295                        rusqlite::params![blob_id],
1296                        |row| row.get::<_, i64>(0),
1297                    )
1298                    .unwrap(),
1299                0
1300            );
1301            result
1302        };
1303        assert_eq!(result.bytes, bytes);
1304
1305        let mut client =
1306            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1307        assert_eq!(
1308            client
1309                .fetch_blob_bytes(&mut transport, "not json")
1310                .unwrap_err(),
1311            (
1312                "client.failed".to_owned(),
1313                "blob ref is not JSON".to_owned()
1314            )
1315        );
1316    }
1317
1318    #[test]
1319    fn visible_blob_references_survive_cache_trimming() {
1320        let schema = json!({"version":1,"tables":[
1321            {"name":"attachments","primaryKey":"id","columns":[
1322                {"name":"id","type":"string","nullable":false},
1323                {"name":"body","type":"blob_ref","nullable":true},
1324                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1325            {"name":"avatars","primaryKey":"id","columns":[
1326                {"name":"id","type":"string","nullable":false},
1327                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1328        ]});
1329        let bytes = b"referenced above cap".to_vec();
1330        let blob_id = blob_id_for(&bytes);
1331        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1332        let mut client = SyncClient::new(
1333            "visible-blob-refs".to_owned(),
1334            &schema,
1335            ClientLimits {
1336                blob_cache_max_bytes: Some(1),
1337                ..Default::default()
1338            },
1339        )
1340        .unwrap();
1341        client.conn.execute(
1342            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1343            rusqlite::params![reference, reference, reference, reference],
1344        ).unwrap();
1345        client
1346            .conn
1347            .execute(
1348                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1349                rusqlite::params![reference],
1350            )
1351            .unwrap();
1352        client.conn.execute(
1353            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1354            [],
1355        ).unwrap();
1356        let mut transport = CountingRealtimeTransport {
1357            blob_download: Some(bytes.clone()),
1358            ..Default::default()
1359        };
1360
1361        assert_eq!(
1362            client
1363                .fetch_blob_bytes(&mut transport, &blob_id)
1364                .unwrap()
1365                .bytes,
1366            bytes
1367        );
1368        let rows = client
1369            .conn
1370            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1371            .unwrap()
1372            .query_map([], |row| row.get::<_, String>(0))
1373            .unwrap()
1374            .collect::<Result<Vec<_>, _>>()
1375            .unwrap();
1376        assert_eq!(rows, vec![blob_id]);
1377    }
1378
1379    #[test]
1380    fn realtime_connection_ownership_is_idempotent() {
1381        let mut client = client();
1382        let mut transport = CountingRealtimeTransport {
1383            connects: 0,
1384            closes: 0,
1385            ..Default::default()
1386        };
1387        client
1388            .connect_realtime(&mut transport)
1389            .expect("first connect");
1390        client
1391            .connect_realtime(&mut transport)
1392            .expect("idempotent connect");
1393        assert_eq!(transport.connects, 1);
1394        client.disconnect_realtime(&mut transport);
1395        client.disconnect_realtime(&mut transport);
1396        assert_eq!(transport.closes, 1);
1397        client
1398            .connect_realtime(&mut transport)
1399            .expect("deliberate reconnect");
1400        assert_eq!(transport.connects, 2);
1401    }
1402
1403    #[test]
1404    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1405        let mut client = client();
1406        client
1407            .set_realtime_policy(RealtimePolicy::Required)
1408            .expect("set policy");
1409        let mut transport = CountingRealtimeTransport::default();
1410        match client.sync(&mut transport) {
1411            SyncOutcome::RealtimeUnavailable {
1412                state,
1413                reason_code,
1414                retry_delay_ms,
1415            } => {
1416                assert_eq!(state, RealtimeState::Disconnected);
1417                assert_eq!(reason_code, None);
1418                assert_eq!(retry_delay_ms, 250);
1419            }
1420            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1421        }
1422        assert_eq!(transport.sync_calls, 0);
1423        assert!(matches!(
1424            client.drain_sync_intents().as_slice(),
1425            [SyncIntent::Background { delay_ms: 250 }]
1426        ));
1427        let snapshot = client
1428            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1429            .expect("diagnostics");
1430        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1431        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1432        assert_eq!(snapshot.host.realtime_reason_code, None);
1433        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1434        assert_eq!(
1435            snapshot
1436                .last_round
1437                .as_ref()
1438                .and_then(|round| round.error_code.clone()),
1439            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1440        );
1441    }
1442
1443    #[test]
1444    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1445        let mut client = client();
1446        client
1447            .set_realtime_policy(RealtimePolicy::Required)
1448            .expect("set policy");
1449        let mut transport = CountingRealtimeTransport {
1450            refuse_connect: true,
1451            ..Default::default()
1452        };
1453        assert!(client.connect_realtime(&mut transport).is_err());
1454        assert_eq!(client.realtime_state(), RealtimeState::Refused);
1455        let snapshot = client
1456            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1457            .expect("diagnostics");
1458        assert_eq!(
1459            snapshot.host.realtime_reason_code.as_deref(),
1460            Some("transport.failed")
1461        );
1462        match client.sync(&mut transport) {
1463            SyncOutcome::RealtimeUnavailable {
1464                state, reason_code, ..
1465            } => {
1466                assert_eq!(state, RealtimeState::Refused);
1467                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1468            }
1469            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1470        }
1471        assert_eq!(transport.sync_calls, 0);
1472        assert_eq!(transport.realtime_sync_calls, 0);
1473    }
1474
1475    #[test]
1476    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1477        let mut client = client();
1478        client
1479            .set_realtime_policy(RealtimePolicy::Required)
1480            .expect("set policy");
1481        let mut transport = CountingRealtimeTransport::default();
1482        client.connect_realtime(&mut transport).expect("connect");
1483        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1484        assert!(
1485            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1486            "a policy change cannot silently re-route a connected socket"
1487        );
1488        assert!(matches!(
1489            client.sync(&mut transport),
1490            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1491        ));
1492        assert_eq!(client.realtime_state(), RealtimeState::Lost);
1493        assert_eq!(transport.realtime_sync_calls, 1);
1494        assert_eq!(transport.sync_calls, 0);
1495        let snapshot = client
1496            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1497            .expect("diagnostics");
1498        assert_eq!(
1499            snapshot.host.realtime_reason_code.as_deref(),
1500            Some("sync.transport_failed")
1501        );
1502        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1503        match client.sync(&mut transport) {
1504            SyncOutcome::RealtimeUnavailable {
1505                state, reason_code, ..
1506            } => {
1507                assert_eq!(state, RealtimeState::Lost);
1508                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1509            }
1510            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1511        }
1512        assert_eq!(transport.sync_calls, 0);
1513        client.disconnect_realtime(&mut transport);
1514        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1515        assert_eq!(transport.closes, 1);
1516        client.connect_realtime(&mut transport).expect("reconnect");
1517        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1518        assert_eq!(transport.connects, 2);
1519    }
1520
1521    #[test]
1522    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1523        let mut client = client();
1524        client
1525            .set_realtime_policy(RealtimePolicy::Off)
1526            .expect("set policy");
1527        let mut transport = CountingRealtimeTransport::default();
1528        assert!(client.connect_realtime(&mut transport).is_err());
1529        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1530        assert!(matches!(
1531            client.sync(&mut transport),
1532            SyncOutcome::Failed { .. }
1533        ));
1534        assert_eq!(transport.sync_calls, 1);
1535        assert_eq!(transport.realtime_sync_calls, 0);
1536        let snapshot = client
1537            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1538            .expect("diagnostics");
1539        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1540        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1541    }
1542
1543    #[test]
1544    fn optional_policy_keeps_the_http_round() {
1545        let mut client = client();
1546        let mut transport = CountingRealtimeTransport::default();
1547        assert!(matches!(
1548            client.sync(&mut transport),
1549            SyncOutcome::Failed { .. }
1550        ));
1551        assert_eq!(transport.sync_calls, 1);
1552        assert_eq!(transport.realtime_sync_calls, 0);
1553        let snapshot = client
1554            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1555            .expect("diagnostics");
1556        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1557        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1558    }
1559
1560    #[test]
1561    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1562        let mut client = client();
1563        client
1564            .subscribe(
1565                "repair-tasks".to_owned(),
1566                "tasks".to_owned(),
1567                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1568                None,
1569            )
1570            .expect("subscribe");
1571        {
1572            let sub = client
1573                .subs
1574                .iter_mut()
1575                .find(|sub| sub.id == "repair-tasks")
1576                .expect("subscription");
1577            sub.cursor = 42;
1578            sub.synced_once = true;
1579            let persisted = sub.clone();
1580            client.persist_sub(&persisted).unwrap();
1581        }
1582        for table in ["_syncular_base_tasks", "tasks"] {
1583            client
1584                .conn
1585                .execute(
1586                    &format!(
1587                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1588                    ),
1589                    rusqlite::params!["server-row", "p1"],
1590                )
1591                .expect("seed server row");
1592        }
1593        let pending = client
1594            .mutate(vec![Mutation::Upsert {
1595                table: "tasks".to_owned(),
1596                values: Map::from_iter([
1597                    ("id".to_owned(), Value::from("offline-row")),
1598                    ("project_id".to_owned(), Value::from("p1")),
1599                ]),
1600                base_version: None,
1601            }])
1602            .expect("queue offline work");
1603        client.drain_change_batches();
1604        client.drain_sync_intents();
1605
1606        assert_eq!(
1607            client
1608                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1609                    rebootstrap_id: "support-case-001".to_owned(),
1610                })
1611                .expect("rebootstrap"),
1612            LocalDataRebootstrapResult {
1613                already_applied: false,
1614                retained_commits: 1,
1615                reset_subscriptions: 1,
1616            }
1617        );
1618        let visible_ids = client
1619            .conn
1620            .prepare("SELECT id FROM tasks ORDER BY id")
1621            .expect("prepare visible ids")
1622            .query_map([], |row| row.get::<_, String>(0))
1623            .expect("query visible ids")
1624            .collect::<Result<Vec<_>, _>>()
1625            .expect("collect visible ids");
1626        assert_eq!(visible_ids, vec!["offline-row"]);
1627        assert_eq!(client.pending_commit_ids(), vec![pending]);
1628        assert_eq!(
1629            client
1630                .subscription_state("repair-tasks")
1631                .expect("subscription")
1632                .cursor,
1633            -1
1634        );
1635        assert!(client.upgrading());
1636        assert!(client.sync_needed());
1637        assert!(matches!(
1638            client.drain_sync_intents().as_slice(),
1639            [SyncIntent::Interactive]
1640        ));
1641        assert_eq!(client.drain_change_batches().len(), 1);
1642
1643        assert_eq!(
1644            client
1645                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1646                    rebootstrap_id: "support-case-001".to_owned(),
1647                })
1648                .expect("idempotent retry"),
1649            LocalDataRebootstrapResult {
1650                already_applied: true,
1651                retained_commits: 1,
1652                reset_subscriptions: 1,
1653            }
1654        );
1655        assert!(client.drain_change_batches().is_empty());
1656    }
1657
1658    #[test]
1659    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1660        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1661        assert_eq!(
1662            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1663            (3, 4)
1664        );
1665        assert_eq!(
1666            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1667            (0, 0)
1668        );
1669        for malformed in [
1670            "",
1671            "{}",
1672            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1673            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1674            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1675        ] {
1676            assert_eq!(
1677                decode_local_rebootstrap_receipt(malformed)
1678                    .expect_err("malformed receipt must fail"),
1679                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1680            );
1681        }
1682    }
1683
1684    #[test]
1685    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1686        let path = std::env::temp_dir().join(format!(
1687            "syncular-rebootstrap-receipt-{}.db",
1688            uuid::Uuid::new_v4()
1689        ));
1690        let schema = json!({
1691            "version": 1,
1692            "tables": [{
1693                "name": "tasks",
1694                "primaryKey": "id",
1695                "columns": [
1696                    { "name": "id", "type": "string", "nullable": false },
1697                    { "name": "project_id", "type": "string", "nullable": false }
1698                ],
1699                "scopes": [{ "pattern": "project:{project_id}" }]
1700            }]
1701        });
1702        let path_string = path.to_str().expect("UTF-8 temp path");
1703
1704        {
1705            let mut first = SyncClient::open_path(
1706                "repair-restart-client".to_owned(),
1707                &schema,
1708                ClientLimits::default(),
1709                path_string,
1710            )
1711            .expect("first open");
1712            first
1713                .subscribe(
1714                    "repair-tasks".to_owned(),
1715                    "tasks".to_owned(),
1716                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1717                    None,
1718                )
1719                .expect("subscribe");
1720            first
1721                .mutate(vec![Mutation::Upsert {
1722                    table: "tasks".to_owned(),
1723                    values: Map::from_iter([
1724                        ("id".to_owned(), Value::from("offline-row")),
1725                        ("project_id".to_owned(), Value::from("p1")),
1726                    ]),
1727                    base_version: None,
1728                }])
1729                .expect("queue offline work");
1730            assert_eq!(
1731                first
1732                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1733                        rebootstrap_id: "restart-receipt".to_owned(),
1734                    })
1735                    .expect("first rebootstrap"),
1736                LocalDataRebootstrapResult {
1737                    already_applied: false,
1738                    retained_commits: 1,
1739                    reset_subscriptions: 1,
1740                }
1741            );
1742        }
1743
1744        let mut reopened = SyncClient::open_path(
1745            "repair-restart-client".to_owned(),
1746            &schema,
1747            ClientLimits::default(),
1748            path_string,
1749        )
1750        .expect("reopen");
1751        reopened.drain_change_batches();
1752        reopened.drain_sync_intents();
1753        assert_eq!(
1754            reopened
1755                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1756                    rebootstrap_id: "restart-receipt".to_owned(),
1757                })
1758                .expect("receipt replay"),
1759            LocalDataRebootstrapResult {
1760                already_applied: true,
1761                retained_commits: 1,
1762                reset_subscriptions: 1,
1763            }
1764        );
1765        assert!(reopened.drain_change_batches().is_empty());
1766        assert!(reopened.drain_sync_intents().is_empty());
1767        drop(reopened);
1768        std::fs::remove_file(path).expect("remove temp database");
1769    }
1770
1771    #[test]
1772    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1773        let mut malformed = client();
1774        malformed
1775            .subscribe(
1776                "repair-tasks".to_owned(),
1777                "tasks".to_owned(),
1778                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1779                None,
1780            )
1781            .expect("subscribe");
1782        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1783        malformed.drain_change_batches();
1784        malformed.drain_sync_intents();
1785        let malformed_error = malformed
1786            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1787                rebootstrap_id: "malformed".to_owned(),
1788            })
1789            .expect_err("malformed receipt must fail closed");
1790        assert_eq!(
1791            malformed_error,
1792            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1793        );
1794        assert!(!malformed.upgrading());
1795        assert_eq!(
1796            malformed
1797                .subscription_state("repair-tasks")
1798                .expect("unchanged subscription")
1799                .cursor,
1800            -1
1801        );
1802        assert!(malformed.drain_change_batches().is_empty());
1803        assert!(malformed.drain_sync_intents().is_empty());
1804
1805        let mut unreadable = client();
1806        unreadable
1807            .conn
1808            .execute(
1809                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1810                rusqlite::params!["server-row", "p1"],
1811            )
1812            .expect("seed visible row");
1813        unreadable
1814            .conn
1815            .execute("DROP TABLE _syncular_meta", [])
1816            .expect("break marker storage");
1817        let unreadable_error = unreadable
1818            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1819                rebootstrap_id: "unreadable".to_owned(),
1820            })
1821            .expect_err("unreadable marker storage must fail closed");
1822        assert_eq!(
1823            unreadable_error,
1824            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1825        );
1826        let visible_rows = unreadable
1827            .conn
1828            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1829            .expect("visible projection remains");
1830        assert_eq!(visible_rows, 1);
1831        assert!(!unreadable.upgrading());
1832        assert!(unreadable.drain_change_batches().is_empty());
1833        assert!(unreadable.drain_sync_intents().is_empty());
1834    }
1835
1836    #[test]
1837    fn local_rebootstrap_cannot_bypass_schema_floor() {
1838        let mut client = client();
1839        client.set_schema_floor(Some(SchemaFloor {
1840            required_schema_version: Some(2),
1841            latest_schema_version: Some(2),
1842        }));
1843        let error = client
1844            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1845                rebootstrap_id: "blocked-floor".to_owned(),
1846            })
1847            .expect_err("schema floor must block repair");
1848        assert!(error.contains("cannot bypass an active schema-floor stop"));
1849    }
1850
1851    #[test]
1852    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1853        let mut client = client();
1854        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1855        const COMMIT_COUNT: usize = 32;
1856
1857        for index in 0..COMMIT_COUNT {
1858            client
1859                .mutate(vec![Mutation::Upsert {
1860                    table: "tasks".to_owned(),
1861                    values: Map::from_iter([
1862                        ("id".to_owned(), Value::from(format!("task-{index}"))),
1863                        ("project_id".to_owned(), Value::from("project-1")),
1864                    ]),
1865                    base_version: None,
1866                }])
1867                .expect("queue commit");
1868        }
1869
1870        let (_, request_meta) = client.build_request(false);
1871        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1872        let mut frames = vec![Frame::RespHeader {
1873            required_schema_version: None,
1874            latest_schema_version: None,
1875            log_epoch: Some("epoch-1".to_owned()),
1876            reset_required: Some(false),
1877        }];
1878        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1879            |(index, client_commit_id)| Frame::PushResult {
1880                client_commit_id: client_commit_id.clone(),
1881                status: PushStatus::Applied,
1882                commit_seq: Some(index as i64 + 1),
1883                results: vec![OpResult::Applied { op_index: 0 }],
1884            },
1885        ));
1886        let response = Message {
1887            wire_version: WIRE_VERSION,
1888            msg_kind: MsgKind::Response,
1889            frames,
1890        };
1891        let mut transport =
1892            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1893
1894        client.overlay_rebuild_count.set(0);
1895        client.outcome_prune_count.set(0);
1896        let outcome = client.process_response(&mut transport, response, &request_meta);
1897        assert!(matches!(outcome, SyncOutcome::Ok(_)));
1898        assert!(client.pending_commit_ids().is_empty());
1899        assert_eq!(
1900            client.overlay_rebuild_count.get(),
1901            1,
1902            "one response must reconcile its acknowledged commits with one overlay rebuild"
1903        );
1904        assert_eq!(
1905            client.outcome_prune_count.get(),
1906            1,
1907            "one response must enforce outcome retention once"
1908        );
1909    }
1910
1911    #[test]
1912    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1913        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1914            "columns": [
1915                {"name":"id", "type":"string", "nullable":false},
1916                {"name":"project_id", "type":"string", "nullable":false},
1917                {"name":"title", "type":"string", "nullable":false}
1918            ], "scopes": [{"pattern":"project:{project_id}"}],
1919            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1920            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1921        }] });
1922        let mut incremental =
1923            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1924        let mut reference =
1925            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1926        let tables = vec!["tasks".to_owned()];
1927        for index in 0..32 {
1928            let id = format!("task-{}", index % 8);
1929            let values = Map::from_iter([
1930                ("id".to_owned(), Value::from(id.clone())),
1931                (
1932                    "project_id".to_owned(),
1933                    Value::from(format!("p{}", index % 2)),
1934                ),
1935                ("title".to_owned(), Value::from(format!("needle{index}"))),
1936            ]);
1937            let payload = encode_row_json(
1938                incremental.schema.table("tasks").unwrap(),
1939                &id,
1940                &values,
1941                &incremental.encryption,
1942            )
1943            .unwrap();
1944            let change = ssp2::model::Change {
1945                table_index: 0,
1946                row_id: id,
1947                op: if index % 5 == 0 {
1948                    Op::Delete
1949                } else {
1950                    Op::Upsert
1951                },
1952                row_version: Some(index + 1),
1953                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1954                row: Some(payload),
1955            };
1956            reference.overlay_dirty.set(true);
1957            for instance in [&mut incremental, &mut reference] {
1958                instance.begin_observation("mirror_test").unwrap();
1959                let mut batch = ChangeAccumulator::default();
1960                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1961                instance
1962                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
1963                    .unwrap();
1964                instance.rebuild_overlay_if_dirty();
1965                instance.finish_observation("mirror_test", batch).unwrap();
1966            }
1967            for sql in [
1968                "SELECT * FROM tasks ORDER BY id",
1969                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1970            ] {
1971                assert_eq!(
1972                    incremental.query(sql, &[]).unwrap(),
1973                    reference.query(sql, &[]).unwrap()
1974                );
1975            }
1976            assert_eq!(incremental.local_revision(), reference.local_revision());
1977            assert_eq!(
1978                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1979                serde_json::to_value(reference.drain_change_batches()).unwrap()
1980            );
1981        }
1982        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1983        assert_eq!(reference.overlay_rebuild_count.get(), 32);
1984    }
1985
1986    #[test]
1987    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1988        let cases = [
1989            (
1990                "string",
1991                vec![
1992                    json!("1"),
1993                    json!("01"),
1994                    json!(""),
1995                    json!("nul\u{0}id"),
1996                    json!("é"),
1997                ],
1998            ),
1999            (
2000                "integer",
2001                vec![
2002                    json!(i64::MIN),
2003                    json!(-1),
2004                    json!(0),
2005                    json!(1),
2006                    json!(i64::MAX),
2007                ],
2008            ),
2009            ("boolean", vec![json!(false), json!(true)]),
2010            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2011        ];
2012        for (kind, ids) in cases {
2013            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2014                "columns":[{"name":"id","type":kind,"nullable":false},
2015                    {"name":"project_id","type":"string","nullable":false}],
2016                "scopes":[{"pattern":"project:{project_id}"}]}]});
2017            let mut client =
2018                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2019            for (index, id) in ids.iter().enumerate() {
2020                let table = client.schema.table("tasks").unwrap();
2021                client
2022                    .write_base_row(
2023                        "tasks",
2024                        &vec![
2025                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2026                            Some(ColumnValue::String(format!("p{index}"))),
2027                        ],
2028                        1,
2029                    )
2030                    .unwrap();
2031            }
2032            client.rebuild_overlay_if_dirty();
2033            for base in [true, false] {
2034                let full = if base {
2035                    base_table("tasks")
2036                } else {
2037                    visible_table("tasks")
2038                };
2039                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2040                for row_id in [
2041                    "1",
2042                    "01",
2043                    "1.0",
2044                    "1.0000000000000002",
2045                    "+1",
2046                    "1e0",
2047                    " 1",
2048                    "-1",
2049                    "0",
2050                    "9223372036854775807",
2051                    "-9223372036854775808",
2052                    "true",
2053                    "false",
2054                    "",
2055                    "nul\u{0}id",
2056                    "é",
2057                    "null",
2058                    "{\"a\":1}",
2059                ] {
2060                    let old_sql =
2061                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2062                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2063                    let old = client
2064                        .conn
2065                        .prepare(&old_sql)
2066                        .unwrap()
2067                        .query_map([row_id], |row| row.get::<_, String>(0))
2068                        .unwrap()
2069                        .collect::<Result<Vec<_>, _>>()
2070                        .unwrap();
2071                    let new = client
2072                        .conn
2073                        .prepare_cached(&new_sql)
2074                        .unwrap()
2075                        .query_map([row_id], |row| row.get::<_, String>(0))
2076                        .unwrap()
2077                        .collect::<Result<Vec<_>, _>>()
2078                        .unwrap();
2079                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2080                    let mut batch = ChangeAccumulator::default();
2081                    assert_eq!(
2082                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
2083                        !old.is_empty()
2084                    );
2085                    let expected = old.first().map(|scope| {
2086                        BTreeMap::from([(
2087                            "tasks".to_owned(),
2088                            Some(BTreeSet::from([format!("project:{scope}")])),
2089                        )])
2090                    });
2091                    assert_eq!(batch.tables, expected.unwrap_or_default());
2092                    client
2093                        .conn
2094                        .execute_batch("SAVEPOINT lookup_delete")
2095                        .unwrap();
2096                    if base {
2097                        client.delete_base_row("tasks", row_id).unwrap();
2098                    } else {
2099                        client.apply_outbox_ops(&[OutboxOp {
2100                            upsert: false,
2101                            table: "tasks".into(),
2102                            row_id: row_id.into(),
2103                            base_version: None,
2104                            values: None,
2105                        }]);
2106                    }
2107                    let remaining: i64 = client
2108                        .conn
2109                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2110                            row.get(0)
2111                        })
2112                        .unwrap();
2113                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2114                    client
2115                        .conn
2116                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2117                        .unwrap();
2118                    client.overlay_dirty.set(false);
2119                }
2120                for (predicate, expected) in [
2121                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2122                    (predicate, "SEARCH"),
2123                ] {
2124                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2125                    let details = client
2126                        .conn
2127                        .prepare(&sql)
2128                        .unwrap()
2129                        .query_map(["1"], |row| row.get::<_, String>(3))
2130                        .unwrap()
2131                        .collect::<Result<Vec<_>, _>>()
2132                        .unwrap();
2133                    assert!(
2134                        details.iter().any(|detail| detail.contains(expected)),
2135                        "{kind}: {details:?}"
2136                    );
2137                }
2138            }
2139        }
2140    }
2141
2142    #[test]
2143    fn primary_keys_reject_every_ineligible_column_type() {
2144        // §2.4: a `rowId` is a string, so a primary key type whose string form
2145        // differs per renderer, or cannot be reproduced by local storage
2146        // comparison, is a schema error rather than a silent lookup miss.
2147        for ty in ["float", "bytes", "crdt", "blob_ref"] {
2148            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2149                "columns":[{"name":"id","type":ty,"nullable":false},
2150                    {"name":"project_id","type":"string","nullable":false}],
2151                "scopes":[{"pattern":"project:{project_id}"}]}]});
2152            let error =
2153                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2154                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2155                    Err(error) => error,
2156                };
2157            assert!(
2158                error.contains("has an ineligible column type (§2.4)"),
2159                "{ty}: {error}"
2160            );
2161        }
2162        for ty in ["string", "integer", "boolean", "json"] {
2163            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2164                "columns":[{"name":"id","type":ty,"nullable":false},
2165                    {"name":"project_id","type":"string","nullable":false}],
2166                "scopes":[{"pattern":"project:{project_id}"}]}]});
2167            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2168                .unwrap_or_else(|error| panic!("{ty}: {error}"));
2169        }
2170    }
2171
2172    #[test]
2173    fn pending_row_reconciliation_matches_full_fifo_replay() {
2174        for unique in [false, true] {
2175            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2176                "name": name, "primaryKey": "id", "columns": [
2177                    {"name":"id", "type":"string", "nullable":false},
2178                    {"name":"project_id", "type":"string", "nullable":false},
2179                    {"name":"title", "type":"string", "nullable":false}],
2180                "scopes": [{"pattern":"project:{project_id}"}],
2181                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2182                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2183            })))});
2184            let mut incremental =
2185                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2186            let mut reference =
2187                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2188            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2189            for instance in [&mut incremental, &mut reference] {
2190                for table in &tables {
2191                    for index in 0..12 {
2192                        instance
2193                            .write_base_row(
2194                                table,
2195                                &vec![
2196                                    Some(ColumnValue::String(format!("row{index}"))),
2197                                    Some(ColumnValue::String("p0".into())),
2198                                    Some(ColumnValue::String(format!("base{index}"))),
2199                                ],
2200                                1,
2201                            )
2202                            .unwrap();
2203                    }
2204                }
2205                instance.rebuild_overlay_if_dirty();
2206            }
2207            for index in 0..80 {
2208                let mutations = (0..3)
2209                    .map(|offset| {
2210                        let table = tables[(index + offset) % 2].clone();
2211                        let id = format!("row{}", (index + offset) % 16);
2212                        if (index + offset) % 7 == 0 {
2213                            Mutation::Delete {
2214                                table,
2215                                row_id: id,
2216                                base_version: None,
2217                            }
2218                        } else {
2219                            Mutation::Upsert {
2220                                table,
2221                                values: Map::from_iter([
2222                                    ("id".into(), Value::from(id)),
2223                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
2224                                    (
2225                                        "title".into(),
2226                                        Value::from(format!("needle{}", (index + offset) % 16)),
2227                                    ),
2228                                ]),
2229                                base_version: None,
2230                            }
2231                        }
2232                    })
2233                    .collect::<Vec<_>>();
2234                for instance in [&mut incremental, &mut reference] {
2235                    instance.mutate(mutations.clone()).unwrap();
2236                }
2237                // Most frames touch only the table without unique constraints.
2238                // Mixed frames must retain full replay for both tables.
2239                let changes = (0..3)
2240                    .map(|offset| {
2241                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2242                        let id = format!("row{}", (index + offset) % 16);
2243                        let values = Map::from_iter([
2244                            ("id".into(), Value::from(id.clone())),
2245                            (
2246                                "project_id".into(),
2247                                Value::from(format!("p{}", (index + 1) % 3)),
2248                            ),
2249                            (
2250                                "title".into(),
2251                                Value::from(format!("remote{index}_{offset}")),
2252                            ),
2253                        ]);
2254                        ssp2::model::Change {
2255                            table_index: table_index as u16,
2256                            row_id: id.clone(),
2257                            op: if (index + offset) % 5 == 0 {
2258                                Op::Delete
2259                            } else {
2260                                Op::Upsert
2261                            },
2262                            row_version: Some(index as i64 + 2),
2263                            scopes: vec![],
2264                            row: Some(
2265                                encode_row_json(
2266                                    incremental.schema.table(&tables[table_index]).unwrap(),
2267                                    &id,
2268                                    &values,
2269                                    &incremental.encryption,
2270                                )
2271                                .unwrap(),
2272                            ),
2273                        }
2274                    })
2275                    .collect::<Vec<_>>();
2276                reference.overlay_dirty.set(true);
2277                incremental.apply_commit_frame(&tables, &changes).unwrap();
2278                reference.apply_commit_frame(&tables, &changes).unwrap();
2279                for table in &tables {
2280                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2281                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2282                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2283                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2284                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2285                    }
2286                }
2287                assert_eq!(incremental.local_revision(), reference.local_revision());
2288                assert_eq!(
2289                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2290                    serde_json::to_value(reference.drain_change_batches()).unwrap()
2291                );
2292            }
2293            assert_eq!(
2294                incremental.overlay_rebuild_count.get(),
2295                if unique { 81 } else { 21 }
2296            );
2297            assert_eq!(reference.overlay_rebuild_count.get(), 81);
2298        }
2299    }
2300
2301    #[test]
2302    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2303        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2304            "columns":[{"name":"id", "type":"string", "nullable":false},
2305                {"name":"title", "type":"string", "nullable":false}],
2306            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2307        let mut client =
2308            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2309        for (id, title) in [("a", "original"), ("b", "occupied")] {
2310            client
2311                .write_base_row(
2312                    "tasks",
2313                    &vec![
2314                        Some(ColumnValue::String(id.into())),
2315                        Some(ColumnValue::String(title.into())),
2316                    ],
2317                    1,
2318                )
2319                .unwrap();
2320        }
2321        client.rebuild_overlay_if_dirty();
2322        let pending = client
2323            .mutate(vec![Mutation::Upsert {
2324                table: "tasks".into(),
2325                values: Map::from_iter([
2326                    ("id".into(), Value::from("a")),
2327                    ("title".into(), Value::from("available")),
2328                ]),
2329                base_version: None,
2330            }])
2331            .unwrap();
2332        assert_eq!(
2333            client
2334                .conn
2335                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2336                    .get::<_, String>(
2337                    0
2338                ))
2339                .unwrap(),
2340            "available"
2341        );
2342        for (title, expected) in [("available", "original"), ("occupied", "available")] {
2343            let change = ssp2::model::Change {
2344                table_index: 0,
2345                row_id: "b".into(),
2346                op: Op::Upsert,
2347                row_version: Some(2),
2348                scopes: vec![],
2349                row: Some(
2350                    encode_row_json(
2351                        client.schema.table("tasks").unwrap(),
2352                        "b",
2353                        &Map::from_iter([
2354                            ("id".into(), Value::from("b")),
2355                            ("title".into(), Value::from(title)),
2356                        ]),
2357                        &client.encryption,
2358                    )
2359                    .unwrap(),
2360                ),
2361            };
2362            client
2363                .apply_commit_frame(&["tasks".into()], &[change])
2364                .unwrap();
2365            assert_eq!(
2366                client
2367                    .conn
2368                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2369                        .get::<_, String>(
2370                        0
2371                    ))
2372                    .unwrap(),
2373                expected
2374            );
2375            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2376        }
2377        assert_eq!(client.overlay_rebuild_count.get(), 3);
2378    }
2379
2380    #[test]
2381    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2382        for failure in ["visible", "revision", "commit"] {
2383            let mut client = client();
2384            let id = client
2385                .mutate(vec![Mutation::Upsert {
2386                    table: "tasks".into(),
2387                    values: Map::from_iter([
2388                        ("id".into(), Value::from("t1")),
2389                        ("project_id".into(), Value::from("pending")),
2390                    ]),
2391                    base_version: None,
2392                }])
2393                .unwrap();
2394            client.drain_change_batches();
2395            let revision = client.local_revision();
2396            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2397            let trigger = match failure {
2398                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2399                "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"),
2400                _ => "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(),
2401            };
2402            client.conn.execute_batch(&trigger).unwrap();
2403            let change = ssp2::model::Change {
2404                table_index: 0,
2405                row_id: "t1".into(),
2406                op: Op::Upsert,
2407                row_version: Some(2),
2408                scopes: vec![],
2409                row: Some(
2410                    encode_row_json(
2411                        client.schema.table("tasks").unwrap(),
2412                        "t1",
2413                        &Map::from_iter([
2414                            ("id".into(), Value::from("t1")),
2415                            ("project_id".into(), Value::from("remote")),
2416                        ]),
2417                        &client.encryption,
2418                    )
2419                    .unwrap(),
2420                ),
2421            };
2422            assert!(client
2423                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2424                .is_err());
2425            assert!(client.conn.is_autocommit());
2426            assert!(!client.overlay_dirty.get());
2427            assert_eq!(client.local_revision(), revision);
2428            assert!(client.drain_change_batches().is_empty());
2429            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2430            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2431            assert!(client
2432                .query("SELECT * FROM _syncular_base_tasks", &[])
2433                .unwrap()
2434                .is_empty());
2435            client
2436                .conn
2437                .execute_batch("DROP TRIGGER fail_pending")
2438                .unwrap();
2439            client
2440                .apply_commit_frame(&["tasks".into()], &[change])
2441                .unwrap();
2442            assert_eq!(client.local_revision(), revision + 1);
2443            assert_eq!(client.pending_commit_ids(), vec![id]);
2444            // The overlay keeps the pending write visible; the row now carries
2445            // the server row's version it is layered on (§7.1).
2446            let mut expected = before[0].clone();
2447            expected.insert("_syncular_version".to_owned(), Value::from(2));
2448            assert_eq!(
2449                client.query("SELECT * FROM tasks", &[]).unwrap(),
2450                vec![expected]
2451            );
2452            assert_eq!(
2453                client
2454                    .conn
2455                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2456                        .get::<_, String>(0))
2457                    .unwrap(),
2458                "remote"
2459            );
2460        }
2461    }
2462
2463    #[test]
2464    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2465        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2466            "columns": [
2467                {"name":"id", "type":"string", "nullable":false},
2468                {"name":"project_id", "type":"string", "nullable":false},
2469                {"name":"title", "type":"string", "nullable":false}],
2470            "scopes": [{"pattern":"project:{project_id}"}],
2471            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2472            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2473        let mut incremental =
2474            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2475        let mut reference =
2476            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2477        for instance in [&mut incremental, &mut reference] {
2478            for index in 0..16 {
2479                instance
2480                    .write_base_row(
2481                        "tasks",
2482                        &vec![
2483                            Some(ColumnValue::String(format!("task{index}"))),
2484                            Some(ColumnValue::String("p0".to_owned())),
2485                            Some(ColumnValue::String(format!("base{index}"))),
2486                        ],
2487                        1,
2488                    )
2489                    .unwrap();
2490            }
2491            instance.rebuild_overlay_if_dirty();
2492            instance.overlay_rebuild_count.set(0);
2493        }
2494        for index in 0..100 {
2495            let mutations = (0..1 + index % 4)
2496                .map(|offset| {
2497                    let id = format!("task{}", (index + offset) % 24);
2498                    if (index + offset) % 7 == 0 {
2499                        Mutation::Delete {
2500                            table: "tasks".to_owned(),
2501                            row_id: id,
2502                            base_version: None,
2503                        }
2504                    } else {
2505                        Mutation::Upsert {
2506                            table: "tasks".to_owned(),
2507                            values: Map::from_iter([
2508                                ("id".to_owned(), Value::from(id)),
2509                                (
2510                                    "project_id".to_owned(),
2511                                    Value::from(format!("p{}", index % 2)),
2512                                ),
2513                                (
2514                                    "title".to_owned(),
2515                                    Value::from(format!("needle{}", (index + offset) % 24)),
2516                                ),
2517                            ]),
2518                            base_version: None,
2519                        }
2520                    }
2521                })
2522                .collect::<Vec<_>>();
2523            reference.overlay_dirty.set(true);
2524            incremental.mutate(mutations.clone()).unwrap();
2525            reference.mutate(mutations).unwrap();
2526            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2527                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2528                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2529            }
2530            assert_eq!(incremental.local_revision(), reference.local_revision());
2531            assert_eq!(
2532                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2533                serde_json::to_value(reference.drain_change_batches()).unwrap()
2534            );
2535        }
2536        assert_eq!(incremental.outbox.len(), 100);
2537        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2538        assert_eq!(reference.overlay_rebuild_count.get(), 100);
2539    }
2540
2541    #[test]
2542    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2543        for fail_at in ["outbox", "revision"] {
2544            let mut client = client();
2545            let values = Map::from_iter([
2546                ("id".to_owned(), Value::from("t1")),
2547                ("project_id".to_owned(), Value::from("p1")),
2548            ]);
2549            let mutation = Mutation::Upsert {
2550                table: "tasks".to_owned(),
2551                values,
2552                base_version: None,
2553            };
2554            let trigger = if fail_at == "outbox" {
2555                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2556            } else {
2557                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2558            };
2559            client.conn.execute_batch(&trigger).unwrap();
2560            let revision = client.local_revision();
2561            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2562            assert_eq!(client.local_revision(), revision);
2563            assert!(client.outbox.is_empty());
2564            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2565            assert_eq!(
2566                client
2567                    .conn
2568                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2569                        .get::<_, i64>(0))
2570                    .unwrap(),
2571                0
2572            );
2573            assert!(client.drain_change_batches().is_empty());
2574            assert!(!client.overlay_dirty.get());
2575            client
2576                .conn
2577                .execute_batch("DROP TRIGGER fail_append")
2578                .unwrap();
2579            client.mutate(vec![mutation]).unwrap();
2580            assert_eq!(client.outbox.len(), 1);
2581            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2582        }
2583    }
2584
2585    #[test]
2586    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2587        let mut client = client();
2588        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2589        let values = Map::from_iter([
2590            ("id".to_owned(), Value::from("t1")),
2591            ("project_id".to_owned(), Value::from("p1")),
2592        ]);
2593        let payload = encode_row_json(
2594            client.schema.table("tasks").unwrap(),
2595            "t1",
2596            &values,
2597            &client.encryption,
2598        )
2599        .unwrap();
2600        let first = ssp2::model::Change {
2601            table_index: 0,
2602            row_id: "t1".to_owned(),
2603            op: Op::Upsert,
2604            row_version: Some(1),
2605            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2606            row: Some(payload),
2607        };
2608        let mut invalid = first.clone();
2609        invalid.table_index = 1;
2610        let revision = client.local_revision();
2611        client.begin_observation("failed_mirror").unwrap();
2612        assert!(client
2613            .apply_commit_changes(&tables, &[first, invalid])
2614            .is_err());
2615        client.rollback_observation("failed_mirror");
2616        client.rebuild_overlay_if_dirty();
2617        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2618        assert_eq!(
2619            client
2620                .conn
2621                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2622                    .get::<_, i64>(
2623                    0
2624                ))
2625                .unwrap(),
2626            0
2627        );
2628        assert_eq!(client.local_revision(), revision);
2629        assert!(client.drain_change_batches().is_empty());
2630    }
2631
2632    #[test]
2633    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2634        for failure in ["journal", "revision", "commit", "later-frame"] {
2635            let mut client = client();
2636            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2637            for id in ["first", "second"] {
2638                client
2639                    .mutate(vec![Mutation::Upsert {
2640                        table: "tasks".to_owned(),
2641                        values: Map::from_iter([
2642                            ("id".to_owned(), Value::from(id)),
2643                            ("project_id".to_owned(), Value::from("p1")),
2644                        ]),
2645                        base_version: None,
2646                    }])
2647                    .unwrap();
2648            }
2649            let (_, meta) = client.build_request(false);
2650            // A malicious or stale response must not drain an unsent local commit.
2651            client
2652                .mutate(vec![Mutation::Upsert {
2653                    table: "tasks".to_owned(),
2654                    values: Map::from_iter([
2655                        ("id".to_owned(), Value::from("later")),
2656                        ("project_id".to_owned(), Value::from("p1")),
2657                    ]),
2658                    base_version: None,
2659                }])
2660                .unwrap();
2661            let ids = client.pending_commit_ids();
2662            let rows = client
2663                .query("SELECT * FROM tasks ORDER BY id", &[])
2664                .unwrap();
2665            let revision = client.local_revision();
2666            client.drain_change_batches();
2667            if failure != "later-frame" {
2668                client.conn.execute_batch(match failure {
2669                    "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",
2670                    "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",
2671                    _ => "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",
2672                }).unwrap();
2673            }
2674            let mut response = Message {
2675                wire_version: WIRE_VERSION,
2676                msg_kind: MsgKind::Response,
2677                frames: vec![Frame::RespHeader {
2678                    required_schema_version: None,
2679                    latest_schema_version: None,
2680                    log_epoch: Some("epoch-1".to_owned()),
2681                    reset_required: Some(false),
2682                }],
2683            };
2684            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2685                response.frames.push(Frame::PushResult {
2686                    client_commit_id: id.to_owned(),
2687                    status: PushStatus::Applied,
2688                    commit_seq: Some(1),
2689                    results: vec![OpResult::Applied { op_index: 0 }],
2690                });
2691            }
2692            if failure == "later-frame" {
2693                response.frames.push(Frame::Error {
2694                    code: "sync.invalid_request".to_owned(),
2695                    message: "later error".to_owned(),
2696                    category: "protocol".to_owned(),
2697                    retryable: false,
2698                    recommended_action: "retry".to_owned(),
2699                    details: None,
2700                });
2701            }
2702            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2703            let outcome = client.process_response(&mut transport, response.clone(), &meta);
2704            assert!(
2705                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2706            );
2707            if failure == "later-frame" {
2708                assert_eq!(client.pending_commit_ids(), ids[2..]);
2709                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2710                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2711                let batches = client.drain_change_batches();
2712                assert_eq!(batches.len(), 1);
2713                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2714                assert_eq!(client.local_revision(), revision + 1);
2715                response.frames.pop();
2716            } else {
2717                assert_eq!(client.pending_commit_ids(), ids);
2718                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2719                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2720                assert_eq!(client.local_revision(), revision);
2721                assert!(client.drain_change_batches().is_empty());
2722                assert_eq!(
2723                    client
2724                        .query("SELECT * FROM tasks ORDER BY id", &[])
2725                        .unwrap(),
2726                    rows
2727                );
2728                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2729            }
2730            for frame in &mut response.frames {
2731                if let Frame::PushResult { status, .. } = frame {
2732                    *status = PushStatus::Cached;
2733                }
2734            }
2735            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2736            else {
2737                panic!("retry failed");
2738            };
2739            assert_eq!(
2740                report.applied,
2741                if failure == "later-frame" {
2742                    vec![]
2743                } else {
2744                    ids[..2].to_vec()
2745                }
2746            );
2747            assert_eq!(client.pending_commit_ids(), ids[2..]);
2748            let batches = client.drain_change_batches();
2749            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2750            if let Some(batch) = batches.first() {
2751                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2752            }
2753            assert_eq!(client.local_revision(), revision + 1);
2754        }
2755    }
2756
2757    #[test]
2758    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2759        for failure in ["revision", "journal", "commit"] {
2760            for status in ["applied", "cached", "rejected", "conflict"] {
2761                let mut client = client();
2762                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2763                for id in ["first", "later"] {
2764                    client
2765                        .mutate(vec![Mutation::Upsert {
2766                            table: "tasks".to_owned(),
2767                            values: Map::from_iter([
2768                                ("id".to_owned(), Value::from(id)),
2769                                ("project_id".to_owned(), Value::from("p1")),
2770                            ]),
2771                            base_version: None,
2772                        }])
2773                        .unwrap();
2774                }
2775                let (_, meta) = client.build_request(false);
2776                let ids = client.pending_commit_ids();
2777                let rows = client
2778                    .query("SELECT * FROM tasks ORDER BY id", &[])
2779                    .unwrap();
2780                let revision = client.local_revision();
2781                client.drain_change_batches();
2782                let (push_status, results) = match status {
2783                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2784                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2785                    "conflict" => (
2786                        PushStatus::Rejected,
2787                        vec![OpResult::Conflict {
2788                            op_index: 0,
2789                            code: "sync.version_conflict".to_owned(),
2790                            message: "conflict".to_owned(),
2791                            server_version: 1,
2792                            server_row: encode_row_json(
2793                                client.schema.table("tasks").unwrap(),
2794                                "first",
2795                                &Map::from_iter([
2796                                    ("id".to_owned(), Value::from("first")),
2797                                    ("project_id".to_owned(), Value::from("p1")),
2798                                ]),
2799                                &client.encryption,
2800                            )
2801                            .unwrap(),
2802                            conflict_columns: Vec::new(),
2803                        }],
2804                    ),
2805                    _ => (
2806                        PushStatus::Rejected,
2807                        vec![OpResult::Error {
2808                            op_index: 0,
2809                            code: "sync.validation_failed".to_owned(),
2810                            message: "rejected".to_owned(),
2811                            retryable: false,
2812                        }],
2813                    ),
2814                };
2815                let response = Message {
2816                    wire_version: WIRE_VERSION,
2817                    msg_kind: MsgKind::Response,
2818                    frames: vec![
2819                        Frame::RespHeader {
2820                            required_schema_version: None,
2821                            latest_schema_version: None,
2822                            log_epoch: Some("epoch-1".to_owned()),
2823                            reset_required: Some(false),
2824                        },
2825                        Frame::PushResult {
2826                            client_commit_id: ids[0].clone(),
2827                            status: push_status,
2828                            commit_seq: Some(1),
2829                            results,
2830                        },
2831                    ],
2832                };
2833                client.conn.execute_batch(match failure {
2834                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2835                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2836                    _ => "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",
2837                }).unwrap();
2838                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2839                let outcome = client.process_response(&mut transport, response.clone(), &meta);
2840                assert_eq!(
2841                    client.pending_commit_ids(),
2842                    ids,
2843                    "{failure}/{status}: pending identity"
2844                );
2845                assert_eq!(
2846                    client.local_revision(),
2847                    revision,
2848                    "{failure}/{status}: revision"
2849                );
2850                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2851                assert!(client.conflicts().is_empty());
2852                assert!(client.rejections().is_empty());
2853                assert!(client.drain_change_batches().is_empty());
2854                assert_eq!(
2855                    client
2856                        .query("SELECT * FROM tasks ORDER BY id", &[])
2857                        .unwrap(),
2858                    rows
2859                );
2860                assert!(
2861                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2862                    "{failure}/{status}: failed round"
2863                );
2864                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2865                assert!(matches!(
2866                    client.process_response(&mut transport, response, &meta),
2867                    SyncOutcome::Ok(_)
2868                ));
2869                assert_eq!(client.pending_commit_ids(), ids[1..]);
2870                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2871                assert_eq!(client.drain_change_batches().len(), 1);
2872            }
2873        }
2874    }
2875
2876    #[test]
2877    fn mixed_push_results_reconcile_and_prune_once_per_response() {
2878        let mut client = client();
2879        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2880        for index in 0..4 {
2881            client
2882                .mutate(vec![Mutation::Upsert {
2883                    table: "tasks".to_owned(),
2884                    values: Map::from_iter([
2885                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2886                        ("project_id".to_owned(), Value::from("project-1")),
2887                    ]),
2888                    base_version: None,
2889                }])
2890                .expect("queue commit");
2891        }
2892
2893        let (_, request_meta) = client.build_request(false);
2894        let ids = &request_meta.pushed_ids;
2895        assert_eq!(ids.len(), 4);
2896        let response = Message {
2897            wire_version: WIRE_VERSION,
2898            msg_kind: MsgKind::Response,
2899            frames: vec![
2900                Frame::RespHeader {
2901                    required_schema_version: None,
2902                    latest_schema_version: None,
2903                    log_epoch: Some("epoch-1".to_owned()),
2904                    reset_required: Some(false),
2905                },
2906                Frame::PushResult {
2907                    client_commit_id: ids[0].clone(),
2908                    status: PushStatus::Applied,
2909                    commit_seq: Some(1),
2910                    results: vec![OpResult::Applied { op_index: 0 }],
2911                },
2912                Frame::PushResult {
2913                    client_commit_id: ids[1].clone(),
2914                    status: PushStatus::Cached,
2915                    commit_seq: Some(2),
2916                    results: vec![OpResult::Applied { op_index: 0 }],
2917                },
2918                Frame::PushResult {
2919                    client_commit_id: ids[2].clone(),
2920                    status: PushStatus::Rejected,
2921                    commit_seq: None,
2922                    results: vec![OpResult::Error {
2923                        op_index: 0,
2924                        code: "sync.validation_failed".to_owned(),
2925                        message: "rejected".to_owned(),
2926                        retryable: false,
2927                    }],
2928                },
2929                Frame::PushResult {
2930                    client_commit_id: ids[3].clone(),
2931                    status: PushStatus::Rejected,
2932                    commit_seq: None,
2933                    results: vec![OpResult::Error {
2934                        op_index: 0,
2935                        code: "sync.idempotency_cache_miss".to_owned(),
2936                        message: "retry".to_owned(),
2937                        retryable: true,
2938                    }],
2939                },
2940            ],
2941        };
2942        let mut transport =
2943            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2944
2945        client.overlay_rebuild_count.set(0);
2946        client.outcome_prune_count.set(0);
2947        let outcome = client.process_response(&mut transport, response, &request_meta);
2948        let SyncOutcome::Ok(report) = outcome else {
2949            panic!("mixed push-result response failed");
2950        };
2951
2952        assert_eq!(report.applied, ids[..2]);
2953        assert_eq!(report.rejected, ids[2..3]);
2954        assert_eq!(report.retryable, ids[3..4]);
2955        assert_eq!(client.pending_commit_ids(), ids[3..4]);
2956        assert_eq!(
2957            client
2958                .query("SELECT id FROM tasks ORDER BY id", &[])
2959                .expect("query visible overlay"),
2960            vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2961        );
2962        assert_eq!(client.overlay_rebuild_count.get(), 1);
2963        assert_eq!(client.outcome_prune_count.get(), 1);
2964    }
2965
2966    #[test]
2967    fn secondary_unique_collision_preserves_existing_synced_row() {
2968        let client = SyncClient::new(
2969            "unique-upsert-test".to_owned(),
2970            &json!({
2971                "version": 1,
2972                "tables": [{
2973                    "name": "tasks",
2974                    "primaryKey": "id",
2975                    "columns": [
2976                        { "name": "id", "type": "string", "nullable": false },
2977                        { "name": "project_id", "type": "string", "nullable": false },
2978                        { "name": "title", "type": "string", "nullable": false }
2979                    ],
2980                    "scopes": [{ "pattern": "project:{project_id}" }],
2981                    "indexes": [{
2982                        "name": "tasks_by_project_title",
2983                        "columns": ["project_id", "title"],
2984                        "unique": true
2985                    }]
2986                }]
2987            }),
2988            ClientLimits::default(),
2989        )
2990        .expect("test client");
2991        let table = client.schema.table("tasks").expect("tasks table");
2992        let sql = client.insert_row_sql(&base_table("tasks"), table);
2993
2994        client
2995            .conn
2996            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2997            .expect("insert first row");
2998        client
2999            .conn
3000            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3001            .expect("update same primary key");
3002        client
3003            .conn
3004            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3005            .expect("insert second row");
3006        assert!(client
3007            .conn
3008            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3009            .is_err());
3010
3011        let rows = client
3012            .conn
3013            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3014            .expect("prepare rows")
3015            .query_map([], |row| {
3016                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3017            })
3018            .expect("query rows")
3019            .collect::<Result<Vec<_>, _>>()
3020            .expect("collect rows");
3021        assert_eq!(
3022            rows,
3023            vec![
3024                ("t1".to_owned(), "updated".to_owned()),
3025                ("t2".to_owned(), "original".to_owned())
3026            ]
3027        );
3028    }
3029
3030    #[test]
3031    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3032        let path = std::env::temp_dir().join(format!(
3033            "syncular-startup-intent-{}.db",
3034            uuid::Uuid::new_v4()
3035        ));
3036        let schema = json!({
3037            "version": 1,
3038            "tables": [{
3039                "name": "tasks",
3040                "primaryKey": "id",
3041                "columns": [
3042                    { "name": "id", "type": "string", "nullable": false },
3043                    { "name": "project_id", "type": "string", "nullable": false }
3044                ],
3045                "scopes": [{ "pattern": "project:{project_id}" }]
3046            }]
3047        });
3048
3049        {
3050            let mut first = SyncClient::open_path_with_identity(
3051                None,
3052                &schema,
3053                ClientLimits::default(),
3054                path.to_str().expect("UTF-8 temp path"),
3055            )
3056            .expect("first open");
3057            first
3058                .set_window(
3059                    &WindowBase {
3060                        table: "tasks".to_owned(),
3061                        variable: "project_id".to_owned(),
3062                        fixed_scopes: Vec::new(),
3063                        params: None,
3064                    },
3065                    &["persisted".to_owned()],
3066                )
3067                .expect("persist window");
3068        }
3069
3070        let mut reopened = SyncClient::open_path_with_identity(
3071            None,
3072            &schema,
3073            ClientLimits::default(),
3074            path.to_str().expect("UTF-8 temp path"),
3075        )
3076        .expect("reopen");
3077        assert!(reopened.sync_needed());
3078        assert!(matches!(
3079            reopened.drain_sync_intents().as_slice(),
3080            [SyncIntent::Interactive]
3081        ));
3082        drop(reopened);
3083        std::fs::remove_file(path).expect("remove temp database");
3084    }
3085
3086    #[test]
3087    fn reopening_preserves_immutable_subscription_identity_and_progress() {
3088        let path = std::env::temp_dir().join(format!(
3089            "syncular-subscription-identity-{}.db",
3090            uuid::Uuid::new_v4()
3091        ));
3092        let schema = json!({
3093            "version": 1,
3094            "tables": [
3095                {
3096                    "name": "tasks",
3097                    "primaryKey": "id",
3098                    "columns": [
3099                        { "name": "id", "type": "string", "nullable": false },
3100                        { "name": "project_id", "type": "string", "nullable": false }
3101                    ],
3102                    "scopes": [{ "pattern": "project:{project_id}" }]
3103                },
3104                {
3105                    "name": "docs",
3106                    "primaryKey": "id",
3107                    "columns": [
3108                        { "name": "id", "type": "string", "nullable": false },
3109                        { "name": "org_id", "type": "string", "nullable": false },
3110                        { "name": "project_id", "type": "string", "nullable": false }
3111                    ],
3112                    "scopes": [
3113                        { "pattern": "org:{org_id}" },
3114                        { "pattern": "project:{projectId}", "column": "project_id" }
3115                    ]
3116                }
3117            ]
3118        });
3119
3120        {
3121            let mut first = SyncClient::open_path_with_identity(
3122                None,
3123                &schema,
3124                ClientLimits::default(),
3125                path.to_str().expect("UTF-8 temp path"),
3126            )
3127            .expect("first open");
3128            first
3129                .subscribe(
3130                    "stable-subscription".to_owned(),
3131                    "tasks".to_owned(),
3132                    vec![(
3133                        "project_id".to_owned(),
3134                        vec!["p2".to_owned(), "p1".to_owned()],
3135                    )],
3136                    Some(r#"{"view":"v1"}"#.to_owned()),
3137                )
3138                .expect("persist subscription");
3139            let persisted = {
3140                let subscription = first
3141                    .subs
3142                    .iter_mut()
3143                    .find(|subscription| subscription.id == "stable-subscription")
3144                    .expect("subscription");
3145                subscription.cursor = 41;
3146                subscription.bootstrap_state = Some("resume-token".to_owned());
3147                subscription.effective = Some(vec![(
3148                    "project_id".to_owned(),
3149                    vec!["p1".to_owned(), "p2".to_owned()],
3150                )]);
3151                subscription.synced_once = true;
3152                subscription.clone()
3153            };
3154            first.persist_sub(&persisted).unwrap();
3155        }
3156
3157        let mut reopened = SyncClient::open_path_with_identity(
3158            None,
3159            &schema,
3160            ClientLimits::default(),
3161            path.to_str().expect("UTF-8 temp path"),
3162        )
3163        .expect("reopen");
3164        let progress = reopened
3165            .subscription_state("stable-subscription")
3166            .expect("persisted state");
3167        assert_eq!(progress.cursor, 41);
3168        assert!(progress.has_resume_token);
3169
3170        reopened
3171            .subscribe(
3172                "stable-subscription".to_owned(),
3173                "tasks".to_owned(),
3174                vec![(
3175                    "project_id".to_owned(),
3176                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3177                )],
3178                Some(r#"{"view":"v1"}"#.to_owned()),
3179            )
3180            .expect("canonical intent is idempotent");
3181        assert_eq!(
3182            reopened
3183                .subscription_state("stable-subscription")
3184                .expect("unchanged state")
3185                .cursor,
3186            progress.cursor
3187        );
3188
3189        for (table, scopes, params) in [
3190            (
3191                "tasks",
3192                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3193                Some(r#"{"view":"v1"}"#.to_owned()),
3194            ),
3195            (
3196                "tasks",
3197                vec![(
3198                    "project_id".to_owned(),
3199                    vec!["p2".to_owned(), "p1".to_owned()],
3200                )],
3201                Some(r#"{"view":"v2"}"#.to_owned()),
3202            ),
3203            (
3204                "docs",
3205                vec![
3206                    ("org_id".to_owned(), vec!["o1".to_owned()]),
3207                    ("projectId".to_owned(), vec!["p1".to_owned()]),
3208                ],
3209                Some(r#"{"view":"v1"}"#.to_owned()),
3210            ),
3211        ] {
3212            let error = reopened
3213                .subscribe(
3214                    "stable-subscription".to_owned(),
3215                    table.to_owned(),
3216                    scopes,
3217                    params,
3218                )
3219                .expect_err("identity rebind must fail");
3220            assert!(error.starts_with("client.subscription_intent_mismatch:"));
3221            assert_eq!(
3222                reopened
3223                    .subscription_state("stable-subscription")
3224                    .expect("unchanged state")
3225                    .cursor,
3226                progress.cursor
3227            );
3228        }
3229
3230        drop(reopened);
3231        std::fs::remove_file(path).expect("remove temp database");
3232    }
3233
3234    #[test]
3235    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3236        let path = std::env::temp_dir().join(format!(
3237            "syncular-satisfied-schema-floor-{}.db",
3238            uuid::Uuid::new_v4()
3239        ));
3240        let schema = json!({
3241            "version": 23,
3242            "tables": [{
3243                "name": "tasks",
3244                "primaryKey": "id",
3245                "columns": [
3246                    { "name": "id", "type": "string", "nullable": false },
3247                    { "name": "project_id", "type": "string", "nullable": false }
3248                ],
3249                "scopes": [{ "pattern": "project:{project_id}" }]
3250            }]
3251        });
3252
3253        {
3254            let mut first = SyncClient::open_path_with_identity(
3255                None,
3256                &schema,
3257                ClientLimits::default(),
3258                path.to_str().expect("UTF-8 temp path"),
3259            )
3260            .expect("first open");
3261            first
3262                .subscribe(
3263                    "tasks".to_owned(),
3264                    "tasks".to_owned(),
3265                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3266                    None,
3267                )
3268                .expect("persist subscription");
3269            first.set_schema_floor(Some(SchemaFloor {
3270                required_schema_version: Some(22),
3271                latest_schema_version: Some(22),
3272            }));
3273        }
3274
3275        let mut reopened = SyncClient::open_path_with_identity(
3276            None,
3277            &schema,
3278            ClientLimits::default(),
3279            path.to_str().expect("UTF-8 temp path"),
3280        )
3281        .expect("reopen");
3282        assert!(reopened.schema_floor().is_none());
3283        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3284        assert!(reopened.sync_needed());
3285        assert!(matches!(
3286            reopened.drain_sync_intents().as_slice(),
3287            [SyncIntent::Interactive]
3288        ));
3289        drop(reopened);
3290        std::fs::remove_file(path).expect("remove temp database");
3291    }
3292
3293    #[test]
3294    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3295        let path = std::env::temp_dir().join(format!(
3296            "syncular-unsatisfied-schema-floor-{}.db",
3297            uuid::Uuid::new_v4()
3298        ));
3299        let schema = json!({
3300            "version": 1,
3301            "tables": [{
3302                "name": "tasks",
3303                "primaryKey": "id",
3304                "columns": [
3305                    { "name": "id", "type": "string", "nullable": false },
3306                    { "name": "project_id", "type": "string", "nullable": false }
3307                ],
3308                "scopes": [{ "pattern": "project:{project_id}" }]
3309            }]
3310        });
3311
3312        {
3313            let mut first = SyncClient::open_path_with_identity(
3314                None,
3315                &schema,
3316                ClientLimits::default(),
3317                path.to_str().expect("UTF-8 temp path"),
3318            )
3319            .expect("first open");
3320            first
3321                .subscribe(
3322                    "tasks".to_owned(),
3323                    "tasks".to_owned(),
3324                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3325                    None,
3326                )
3327                .expect("persist subscription");
3328            first.set_schema_floor(Some(SchemaFloor {
3329                required_schema_version: Some(2),
3330                latest_schema_version: Some(2),
3331            }));
3332        }
3333
3334        let reopened = SyncClient::open_path_with_identity(
3335            None,
3336            &schema,
3337            ClientLimits::default(),
3338            path.to_str().expect("UTF-8 temp path"),
3339        )
3340        .expect("reopen");
3341        assert_eq!(
3342            reopened.schema_floor(),
3343            Some(&SchemaFloor {
3344                required_schema_version: Some(2),
3345                latest_schema_version: Some(2),
3346            })
3347        );
3348        assert!(!reopened.sync_needed());
3349        drop(reopened);
3350        std::fs::remove_file(path).expect("remove temp database");
3351    }
3352
3353    #[test]
3354    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3355        let path = std::env::temp_dir().join(format!(
3356            "syncular-indexed-column-bump-{}.db",
3357            uuid::Uuid::new_v4()
3358        ));
3359        let old_schema = json!({
3360            "version": 1,
3361            "tables": [
3362                {
3363                    "name": "tasks",
3364                    "primaryKey": "id",
3365                    "columns": [
3366                        { "name": "id", "type": "string", "nullable": false },
3367                        { "name": "project_id", "type": "string", "nullable": false }
3368                    ],
3369                    "scopes": [{ "pattern": "project:{project_id}" }]
3370                },
3371                {
3372                    "name": "legacy",
3373                    "primaryKey": "id",
3374                    "columns": [
3375                        { "name": "id", "type": "string", "nullable": false },
3376                        { "name": "project_id", "type": "string", "nullable": false }
3377                    ],
3378                    "scopes": [{ "pattern": "project:{project_id}" }]
3379                }
3380            ]
3381        });
3382        {
3383            let mut first = SyncClient::open_path_with_identity(
3384                None,
3385                &old_schema,
3386                ClientLimits::default(),
3387                path.to_str().expect("UTF-8 temp path"),
3388            )
3389            .expect("open old schema");
3390            first
3391                .subscribe(
3392                    "legacy-sub".to_owned(),
3393                    "legacy".to_owned(),
3394                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3395                    None,
3396                )
3397                .expect("persist legacy subscription");
3398        }
3399
3400        let new_schema = json!({
3401            "version": 2,
3402            "tables": [{
3403                "name": "tasks",
3404                "primaryKey": "id",
3405                "columns": [
3406                    { "name": "id", "type": "string", "nullable": false },
3407                    { "name": "project_id", "type": "string", "nullable": false },
3408                    { "name": "facility_membership_id", "type": "string", "nullable": true }
3409                ],
3410                "scopes": [{ "pattern": "project:{project_id}" }],
3411                "indexes": [{
3412                    "name": "tasks_by_membership",
3413                    "columns": ["project_id", "facility_membership_id"],
3414                    "unique": false
3415                }]
3416            }]
3417        });
3418        let upgraded = SyncClient::open_path_with_identity(
3419            None,
3420            &new_schema,
3421            ClientLimits::default(),
3422            path.to_str().expect("UTF-8 temp path"),
3423        )
3424        .expect("open upgraded schema");
3425        let columns = upgraded
3426            .conn
3427            .prepare("PRAGMA table_info(tasks)")
3428            .expect("prepare columns")
3429            .query_map([], |row| row.get::<_, String>(1))
3430            .expect("query columns")
3431            .collect::<Result<Vec<_>, _>>()
3432            .expect("collect columns");
3433        assert!(columns.contains(&"facility_membership_id".to_owned()));
3434        let index_count: i64 = upgraded
3435            .conn
3436            .query_row(
3437                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3438                [],
3439                |row| row.get(0),
3440            )
3441            .expect("query index");
3442        assert_eq!(index_count, 1);
3443        assert!(upgraded.subscription_state("legacy-sub").is_none());
3444        drop(upgraded);
3445        std::fs::remove_file(path).expect("remove temp database");
3446    }
3447
3448    #[test]
3449    fn migrates_pre_envelope_outcome_journal_additively() {
3450        let path = std::env::temp_dir().join(format!(
3451            "syncular-outcome-migration-{}.db",
3452            uuid::Uuid::new_v4()
3453        ));
3454        let conn = Connection::open(&path).expect("open legacy database");
3455        conn.execute_batch(
3456            "CREATE TABLE _syncular_commit_outcomes (
3457               seq INTEGER PRIMARY KEY AUTOINCREMENT,
3458               client_commit_id TEXT NOT NULL UNIQUE,
3459               status TEXT NOT NULL,
3460               recorded_at_ms INTEGER NOT NULL,
3461               results_json TEXT NOT NULL,
3462               resolution TEXT NOT NULL DEFAULT 'active',
3463               resolved_at_ms INTEGER,
3464               replacement_client_commit_id TEXT);",
3465        )
3466        .expect("create legacy outcome journal");
3467        drop(conn);
3468        let schema = json!({
3469            "version": 1,
3470            "tables": [{
3471                "name": "tasks",
3472                "primaryKey": "id",
3473                "columns": [
3474                    { "name": "id", "type": "string", "nullable": false },
3475                    { "name": "project_id", "type": "string", "nullable": false }
3476                ],
3477                "scopes": [{ "pattern": "project:{project_id}" }]
3478            }]
3479        });
3480        let client = SyncClient::open_path(
3481            "migration-native".to_owned(),
3482            &schema,
3483            ClientLimits::default(),
3484            path.to_str().expect("UTF-8 temp path"),
3485        )
3486        .expect("migrate database");
3487        let has_operations = client
3488            .conn
3489            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3490            .expect("prepare table info")
3491            .query_map([], |row| row.get::<_, String>(1))
3492            .expect("query table info")
3493            .filter_map(Result::ok)
3494            .any(|column| column == "operations_json");
3495        assert!(has_operations);
3496        drop(client);
3497        std::fs::remove_file(path).expect("remove temp database");
3498    }
3499
3500    #[test]
3501    fn durable_conflict_outcome_and_resolution_survive_reopen() {
3502        let path = std::env::temp_dir().join(format!(
3503            "syncular-durable-outcome-{}.db",
3504            uuid::Uuid::new_v4()
3505        ));
3506        let schema = json!({
3507            "version": 1,
3508            "tables": [{
3509                "name": "tasks",
3510                "primaryKey": "id",
3511                "columns": [
3512                    { "name": "id", "type": "string", "nullable": false },
3513                    { "name": "project_id", "type": "string", "nullable": false }
3514                ],
3515                "scopes": [{ "pattern": "project:{project_id}" }]
3516            }]
3517        });
3518        let conflict = ConflictRecord {
3519            client_commit_id: "losing-commit".to_owned(),
3520            op_index: 0,
3521            table: "tasks".to_owned(),
3522            row_id: "t1".to_owned(),
3523            code: "sync.version_conflict".to_owned(),
3524            message: "stale base version".to_owned(),
3525            server_version: 2,
3526            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3527            conflict_columns: vec!["project_id".to_owned()],
3528            operation: Some(CommitOperation {
3529                table: "tasks".to_owned(),
3530                row_id: "t1".to_owned(),
3531                op: "upsert".to_owned(),
3532                base_version: Some(1),
3533                values: None,
3534            }),
3535        };
3536        let failed_operations = vec![
3537            OutboxOp {
3538                upsert: true,
3539                table: "tasks".to_owned(),
3540                row_id: "t1".to_owned(),
3541                base_version: Some(1),
3542                values: None,
3543            },
3544            OutboxOp {
3545                upsert: true,
3546                table: "tasks".to_owned(),
3547                row_id: "status-event-1".to_owned(),
3548                base_version: Some(0),
3549                values: None,
3550            },
3551        ];
3552
3553        {
3554            let mut first = SyncClient::open_path(
3555                "durable-native".to_owned(),
3556                &schema,
3557                ClientLimits::default(),
3558                path.to_str().expect("UTF-8 temp path"),
3559            )
3560            .expect("first open");
3561            first
3562                .begin_observation("test_outcome")
3563                .expect("begin outcome");
3564            first
3565                .persist_commit_outcome(
3566                    "losing-commit",
3567                    CommitOutcomeStatus::Conflict,
3568                    &[CommitOperationOutcome::Conflict {
3569                        conflict: conflict.clone(),
3570                    }],
3571                    Some(&failed_operations),
3572                )
3573                .expect("persist outcome");
3574            first.conflicts.push(conflict);
3575            first
3576                .finish_observation(
3577                    "test_outcome",
3578                    ChangeAccumulator {
3579                        conflicts: true,
3580                        outcomes: true,
3581                        ..ChangeAccumulator::default()
3582                    },
3583                )
3584                .expect("commit outcome");
3585        }
3586
3587        {
3588            let mut reopened = SyncClient::open_path(
3589                "durable-native".to_owned(),
3590                &schema,
3591                ClientLimits::default(),
3592                path.to_str().expect("UTF-8 temp path"),
3593            )
3594            .expect("reopen");
3595            assert_eq!(reopened.conflicts().len(), 1);
3596            assert_eq!(
3597                reopened.conflicts()[0].conflict_columns,
3598                vec!["project_id".to_owned()],
3599                "conflictColumns survives the journal round trip"
3600            );
3601            let outcome = reopened
3602                .commit_outcome("losing-commit")
3603                .expect("read outcome")
3604                .expect("outcome");
3605            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3606            let operations = outcome.operations.expect("aggregate envelope");
3607            assert_eq!(operations.len(), 2);
3608            assert_eq!(operations[1].row_id, "status-event-1");
3609            let resolved = reopened
3610                .resolve_commit_outcome(ResolveCommitOutcomeInput {
3611                    client_commit_id: "losing-commit".to_owned(),
3612                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
3613                    replacement_client_commit_id: None,
3614                })
3615                .expect("resolve");
3616            assert_eq!(
3617                resolved.resolution,
3618                CommitOutcomeResolution::ResolvedKeepServer
3619            );
3620            assert!(reopened.conflicts().is_empty());
3621        }
3622
3623        let reopened = SyncClient::open_path(
3624            "durable-native".to_owned(),
3625            &schema,
3626            ClientLimits::default(),
3627            path.to_str().expect("UTF-8 temp path"),
3628        )
3629        .expect("second reopen");
3630        assert!(reopened.conflicts().is_empty());
3631        assert_eq!(
3632            reopened
3633                .commit_outcome("losing-commit")
3634                .expect("read outcome")
3635                .expect("outcome")
3636                .resolution,
3637            CommitOutcomeResolution::ResolvedKeepServer
3638        );
3639        drop(reopened);
3640        std::fs::remove_file(path).expect("remove temp database");
3641    }
3642
3643    #[test]
3644    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3645        let path =
3646            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3647        let schema = json!({
3648            "version": 1,
3649            "tables": [{
3650                "name": "tasks",
3651                "primaryKey": "id",
3652                "columns": [
3653                    { "name": "id", "type": "string", "nullable": false },
3654                    { "name": "project_id", "type": "string", "nullable": false }
3655                ],
3656                "scopes": [{ "pattern": "project:{project_id}" }]
3657            }]
3658        });
3659        let mut client = SyncClient::open_path_with_identity(
3660            Some("sidecar-client".to_owned()),
3661            &schema,
3662            ClientLimits::default(),
3663            path.to_str().expect("UTF-8 temp path"),
3664        )
3665        .expect("open owner");
3666        let base = WindowBase {
3667            table: "tasks".to_owned(),
3668            variable: "project_id".to_owned(),
3669            fixed_scopes: Vec::new(),
3670            params: None,
3671        };
3672        client
3673            .set_window(&base, &["one".to_owned()])
3674            .expect("set window");
3675        client
3676            .mutate(vec![Mutation::Upsert {
3677                table: "tasks".to_owned(),
3678                values: Map::from_iter([
3679                    ("id".to_owned(), Value::from("t1")),
3680                    ("project_id".to_owned(), Value::from("one")),
3681                ]),
3682                base_version: None,
3683            }])
3684            .expect("local mutate");
3685
3686        let coverage = [WindowCoverage {
3687            base,
3688            units: vec!["one".to_owned(), "missing".to_owned()],
3689        }];
3690        let owner = client
3691            .query_snapshot(
3692                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3693                &[],
3694                &coverage,
3695                None,
3696            )
3697            .expect("owner snapshot");
3698        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3699        let sidecar = reader
3700            .query_snapshot(
3701                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3702                &[],
3703                &coverage,
3704            )
3705            .expect("sidecar snapshot");
3706
3707        assert_eq!(sidecar.revision, owner.revision);
3708        assert_eq!(sidecar.rows, owner.rows);
3709        assert_eq!(
3710            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3711            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3712        );
3713        assert_eq!(sidecar.revision, "2");
3714        assert_eq!(sidecar.rows[0]["id"], "t1");
3715        assert_eq!(sidecar.rows[0]["server_version"], -1);
3716        assert!(!sidecar.coverage.complete);
3717        assert_eq!(sidecar.coverage.pending.len(), 1);
3718        assert_eq!(sidecar.coverage.missing.len(), 1);
3719
3720        drop(reader);
3721        drop(client);
3722        std::fs::remove_file(path).expect("remove temp database");
3723    }
3724
3725    #[test]
3726    fn owned_query_failures_classify_order_bound_and_clear() {
3727        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3728            rusqlite::ffi::Error::new(266),
3729            Some("private SQLite prose".to_owned()),
3730        ));
3731        assert_eq!(io.code, Some("client.storage_io"));
3732        assert_eq!(io.sqlite_code, Some(266));
3733        assert_eq!(io.message, "local SQLite storage I/O failed");
3734        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3735            rusqlite::ffi::Error::new(26),
3736            None,
3737        ));
3738        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
3739
3740        let mut client = client();
3741        let generic = QueryReadFailure::from("private query prose".to_owned());
3742        client.record_query_read(
3743            QueryOwner {
3744                id: "first",
3745                tables: &["tasks", "tasks"],
3746            },
3747            Some(&generic),
3748        );
3749        client.record_query_read(
3750            QueryOwner {
3751                id: "second",
3752                tables: &["tasks"],
3753            },
3754            Some(&generic),
3755        );
3756        client.record_query_read(
3757            QueryOwner {
3758                id: "first",
3759                tables: &["docs"],
3760            },
3761            Some(&io),
3762        );
3763        let failures = &client
3764            .diagnostics_snapshot(&Default::default())
3765            .expect("diagnostics")
3766            .query_failures;
3767        assert_eq!(
3768            failures
3769                .iter()
3770                .map(|entry| entry.id.as_str())
3771                .collect::<Vec<_>>(),
3772            vec!["second", "first"]
3773        );
3774        assert_eq!(failures[1].tables, vec!["docs"]);
3775        assert_eq!(failures[1].code, "client.storage_io");
3776
3777        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
3778            client.record_query_read(
3779                QueryOwner {
3780                    id: &format!("query-{index}"),
3781                    tables: &["tasks"],
3782                },
3783                Some(&generic),
3784            );
3785        }
3786        let failures = &client
3787            .diagnostics_snapshot(&Default::default())
3788            .expect("bounded diagnostics")
3789            .query_failures;
3790        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
3791        assert_eq!(failures.last().expect("last failure").id, "query-256");
3792
3793        client
3794            .query_snapshot(
3795                "SELECT id FROM tasks",
3796                &[],
3797                &[],
3798                Some(QueryOwner {
3799                    id: "query-256",
3800                    tables: &["tasks"],
3801                }),
3802            )
3803            .expect("successful owned read");
3804        assert!(client
3805            .diagnostics_snapshot(&Default::default())
3806            .expect("cleared diagnostics")
3807            .query_failures
3808            .iter()
3809            .all(|entry| entry.id != "query-256"));
3810    }
3811
3812    #[test]
3813    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3814        let mut client = client();
3815        let image = Connection::open_in_memory().unwrap();
3816        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3817            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3818            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3819            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3820            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3821        let table = client.schema.table("tasks").unwrap().clone();
3822        let scopes = vec![("project_id".into(), vec!["p1".into()])];
3823        assert!(client
3824            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3825            .is_err());
3826        assert!(client.conn.is_autocommit());
3827        assert_eq!(
3828            client
3829                .query("SELECT count(*) AS n FROM tasks", &[])
3830                .unwrap()[0]["n"],
3831            1024
3832        );
3833        assert_eq!(client.drain_change_batches().len(), 1);
3834        image
3835            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3836            .unwrap();
3837        assert!(matches!(
3838            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3839            Ok(2050)
3840        ));
3841        assert_eq!(
3842            client
3843                .query("SELECT count(*) AS n FROM tasks", &[])
3844                .unwrap()[0]["n"],
3845            2050
3846        );
3847        assert_eq!(client.drain_change_batches().len(), 3);
3848    }
3849
3850    #[test]
3851    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3852        let mut client = client();
3853        let base = WindowBase {
3854            table: "tasks".into(),
3855            variable: "project_id".into(),
3856            fixed_scopes: Vec::new(),
3857            params: None,
3858        };
3859        client
3860            .set_window(&base, &["p1".into(), "p2".into()])
3861            .unwrap();
3862        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3863            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3864            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3865            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3866            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3867        client.drain_change_batches();
3868        assert!(client.set_window(&base, &["p2".into()]).is_err());
3869        assert!(client.conn.is_autocommit());
3870        assert_eq!(
3871            client
3872                .query("SELECT count(*) AS n FROM tasks", &[])
3873                .unwrap()[0]["n"],
3874            1027
3875        );
3876        assert_eq!(client.window_state(&base).units, vec!["p2"]);
3877        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3878        assert_eq!(client.drain_change_batches().len(), 1);
3879        client
3880            .conn
3881            .execute_batch("DROP TRIGGER interrupt_evict")
3882            .unwrap();
3883        client.drain_pending_evictions().unwrap();
3884        assert!(client.load_pending_evictions().unwrap().is_empty());
3885        assert_eq!(
3886            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3887            "held"
3888        );
3889        assert_eq!(client.drain_change_batches().len(), 2);
3890    }
3891
3892    #[test]
3893    fn eviction_marker_failures_roll_back_window_changes() {
3894        let mut client = client();
3895        let base = WindowBase {
3896            table: "tasks".into(),
3897            variable: "project_id".into(),
3898            fixed_scopes: Vec::new(),
3899            params: None,
3900        };
3901        client.set_window(&base, &["p1".into()]).unwrap();
3902        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3903            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3904            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3905            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3906        let revision = client.local_revision();
3907        assert!(client.set_window(&base, &[]).is_err());
3908        assert!(client.conn.is_autocommit());
3909        assert_eq!(client.local_revision(), revision);
3910        assert_eq!(
3911            client
3912                .query("SELECT count(*) AS n FROM tasks", &[])
3913                .unwrap()[0]["n"],
3914            2050
3915        );
3916        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3917        client.conn.execute_batch("DROP TRIGGER fail_marker;
3918            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3919        assert!(client.set_window(&base, &[]).is_err());
3920        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3921            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3922        assert!(client.drain_pending_evictions().is_err());
3923        assert!(client.conn.is_autocommit());
3924        assert_eq!(
3925            client
3926                .query("SELECT count(*) AS n FROM tasks", &[])
3927                .unwrap()[0]["n"],
3928            2
3929        );
3930        assert!(client.set_window(&base, &["p1".into()]).is_err());
3931        assert!(client.conn.is_autocommit());
3932        assert!(client.window_state(&base).units.is_empty());
3933        assert!(client.subs.is_empty());
3934        assert_eq!(
3935            client
3936                .query(
3937                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3938                    &[]
3939                )
3940                .unwrap()[0]["n"],
3941            1
3942        );
3943        client
3944            .conn
3945            .execute_batch("DROP TRIGGER fail_marker")
3946            .unwrap();
3947        client.set_window(&base, &["p1".into()]).unwrap();
3948        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3949    }
3950
3951    #[test]
3952    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3953        let mut client = client();
3954        client
3955            .conn
3956            .execute(
3957                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3958                [],
3959            )
3960            .unwrap();
3961        assert_eq!(
3962            client.drain_pending_evictions().unwrap_err(),
3963            "sync.local_corrupt: invalid pending eviction scopes"
3964        );
3965        assert!(client.conn.is_autocommit());
3966        assert!(client.enqueue_startup_sync_if_needed().is_err());
3967    }
3968
3969    #[test]
3970    fn image_chunks_reject_null_primary_keys() {
3971        let mut client = client();
3972        let image = Connection::open_in_memory().unwrap();
3973        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3974            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3975            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3976            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3977            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3978        let table = client.schema.table("tasks").unwrap().clone();
3979        assert!(client
3980            .apply_sqlite_image(
3981                &image,
3982                &table,
3983                true,
3984                &[("project_id".into(), vec!["p1".into()])],
3985                1025,
3986                7,
3987                "digest"
3988            )
3989            .is_err());
3990        assert!(client.conn.is_autocommit());
3991        assert_eq!(
3992            client
3993                .query("SELECT count(*) AS n FROM tasks", &[])
3994                .unwrap()[0]["n"],
3995            0
3996        );
3997    }
3998
3999    #[test]
4000    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4001        for images in [false, true] {
4002            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4003                "name":name,"primaryKey":"id","columns":[
4004                    {"name":"id","type":"string","nullable":false},
4005                    {"name":"project_id","type":"string","nullable":false},
4006                    {"name":"title","type":"string","nullable":false}],
4007                "scopes":[{"pattern":"project:{project_id}"}],
4008                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4009                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4010            })))});
4011            let mut actual =
4012                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4013            let mut reference =
4014                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4015            let scopes = vec![("project_id".into(), vec!["p1".into()])];
4016            for client in [&mut actual, &mut reference] {
4017                client
4018                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4019                    .unwrap();
4020                client.subs[0].effective = Some(scopes.clone());
4021                for (table, id, title) in [
4022                    ("tasks", "a", "original"),
4023                    ("tasks", "b", "occupied"),
4024                    ("tasks", "c", "deleted"),
4025                    ("notes", "n", "untouched"),
4026                ] {
4027                    client
4028                        .write_base_row(
4029                            table,
4030                            &vec![
4031                                Some(ColumnValue::String(id.into())),
4032                                Some(ColumnValue::String("p1".into())),
4033                                Some(ColumnValue::String(title.into())),
4034                            ],
4035                            1,
4036                        )
4037                        .unwrap();
4038                }
4039                client.rebuild_overlay_if_dirty();
4040                client
4041                    .mutate(vec![
4042                        Mutation::Upsert {
4043                            table: "tasks".into(),
4044                            values: Map::from_iter([
4045                                ("id".into(), Value::from("a")),
4046                                ("project_id".into(), Value::from("p1")),
4047                                ("title".into(), Value::from("available")),
4048                            ]),
4049                            base_version: None,
4050                        },
4051                        Mutation::Delete {
4052                            table: "tasks".into(),
4053                            row_id: "c".into(),
4054                            base_version: None,
4055                        },
4056                        Mutation::Upsert {
4057                            table: "tasks".into(),
4058                            values: Map::from_iter([
4059                                ("id".into(), Value::from("local")),
4060                                ("project_id".into(), Value::from("p1")),
4061                                ("title".into(), Value::from("local value")),
4062                            ]),
4063                            base_version: None,
4064                        },
4065                        Mutation::Upsert {
4066                            table: "notes".into(),
4067                            values: Map::from_iter([
4068                                ("id".into(), Value::from("n")),
4069                                ("project_id".into(), Value::from("p1")),
4070                                ("title".into(), Value::from("pending note")),
4071                            ]),
4072                            base_version: None,
4073                        },
4074                    ])
4075                    .unwrap();
4076            }
4077            let pending = actual.pending_commit_ids();
4078            actual.overlay_rebuild_count.set(0);
4079            for (id, title) in [
4080                ("b", "available"),
4081                ("b", "occupied"),
4082                ("a", "server original"),
4083                ("d", "local value"),
4084                ("d", "released"),
4085                ("c", "redelivered"),
4086            ] {
4087                let table = actual.schema.table("tasks").unwrap().clone();
4088                let values = vec![
4089                    Some(ColumnValue::String(id.into())),
4090                    Some(ColumnValue::String("p1".into())),
4091                    Some(ColumnValue::String(title.into())),
4092                ];
4093                if images {
4094                    let image = Connection::open_in_memory().unwrap();
4095                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4096                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4097                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4098                    image
4099                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4100                        .unwrap();
4101                    assert!(matches!(
4102                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4103                        Ok(1)
4104                    ));
4105                } else {
4106                    let segment = RowsSegment {
4107                        table: "tasks".into(),
4108                        schema_version: 1,
4109                        columns: table.wire_columns.clone(),
4110                        blocks: vec![vec![ssp2::segment::SegmentRow {
4111                            server_version: 2,
4112                            values: values.clone(),
4113                        }]],
4114                    };
4115                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4116                }
4117                reference.write_base_row("tasks", &values, 2).unwrap();
4118                reference.rebuild_overlay();
4119                for table in ["tasks", "notes"] {
4120                    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")] {
4121                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4122                    }
4123                }
4124                assert_eq!(actual.pending_commit_ids(), pending);
4125            }
4126            assert_eq!(
4127                actual.overlay_rebuild_count.get(),
4128                0,
4129                "each chunk must leave unrelated tables alone"
4130            );
4131            let before = actual
4132                .query("SELECT * FROM tasks ORDER BY id", &[])
4133                .unwrap();
4134            let fts_before = actual
4135                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4136                .unwrap();
4137            let revision = actual.local_revision();
4138            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4139            let table = actual.schema.table("tasks").unwrap().clone();
4140            let segment = RowsSegment {
4141                table: "tasks".into(),
4142                schema_version: 1,
4143                columns: table.wire_columns.clone(),
4144                blocks: vec![vec![ssp2::segment::SegmentRow {
4145                    server_version: 3,
4146                    values: vec![
4147                        Some(ColumnValue::String("b".into())),
4148                        Some(ColumnValue::String("p1".into())),
4149                        Some(ColumnValue::String("fresh".into())),
4150                    ],
4151                }]],
4152            };
4153            assert!(actual.apply_segment(0, &segment, false).is_err());
4154            assert!(actual.conn.is_autocommit());
4155            assert_eq!(
4156                actual
4157                    .query("SELECT * FROM tasks ORDER BY id", &[])
4158                    .unwrap(),
4159                before
4160            );
4161            assert_eq!(
4162                actual
4163                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4164                    .unwrap(),
4165                fts_before
4166            );
4167            assert_eq!(actual.local_revision(), revision);
4168            assert_eq!(actual.pending_commit_ids(), pending);
4169        }
4170    }
4171
4172    #[test]
4173    fn import_reconciliation_preserves_typed_pending_primary_keys() {
4174        for (kind, a, b) in [
4175            ("string", json!("a"), json!("b")),
4176            ("integer", json!(1), json!(2)),
4177            ("boolean", json!(true), json!(false)),
4178            ("json", json!("1"), json!("2")),
4179        ] {
4180            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}]}]});
4181            for images in [false, true] {
4182                let mut client =
4183                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4184                let table = client.schema.table("tasks").unwrap().clone();
4185                client
4186                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
4187                    .unwrap();
4188                for (id, title) in [(&a, "original"), (&b, "occupied")] {
4189                    client
4190                        .write_base_row(
4191                            "tasks",
4192                            &vec![
4193                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4194                                Some(ColumnValue::String(title.into())),
4195                            ],
4196                            1,
4197                        )
4198                        .unwrap();
4199                }
4200                client.rebuild_overlay_if_dirty();
4201                client
4202                    .mutate(vec![Mutation::Upsert {
4203                        table: "tasks".into(),
4204                        values: Map::from_iter([
4205                            ("id".into(), a.clone()),
4206                            ("title".into(), json!("available")),
4207                        ]),
4208                        base_version: None,
4209                    }])
4210                    .unwrap();
4211                client.overlay_rebuild_count.set(0);
4212                for (title, expected) in [("available", "original"), ("occupied", "available")] {
4213                    let values = vec![
4214                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4215                        Some(ColumnValue::String(title.into())),
4216                    ];
4217                    if images {
4218                        let image = Connection::open_in_memory().unwrap();
4219                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4220                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4221                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4222                        image
4223                            .execute(
4224                                "INSERT INTO tasks VALUES (?1,?2,2)",
4225                                rusqlite::params![RowParam::Cell(&values[0]), title],
4226                            )
4227                            .unwrap();
4228                        assert!(
4229                            matches!(
4230                                client.apply_sqlite_image(
4231                                    &image,
4232                                    &table,
4233                                    false,
4234                                    &[],
4235                                    1,
4236                                    7,
4237                                    "digest"
4238                                ),
4239                                Ok(1)
4240                            ),
4241                            "{kind}, images={images}"
4242                        );
4243                    } else {
4244                        let segment = RowsSegment {
4245                            table: "tasks".into(),
4246                            schema_version: 1,
4247                            columns: table.wire_columns.clone(),
4248                            blocks: vec![vec![ssp2::segment::SegmentRow {
4249                                server_version: 2,
4250                                values,
4251                            }]],
4252                        };
4253                        assert!(
4254                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
4255                            "{kind}"
4256                        );
4257                    }
4258                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4259                    let actual: String = client
4260                        .conn
4261                        .query_row(
4262                            "SELECT title FROM tasks WHERE id=?1",
4263                            [RowParam::Cell(&key)],
4264                            |row| row.get(0),
4265                        )
4266                        .unwrap();
4267                    assert_eq!(actual, expected, "{kind}, images={images}");
4268                }
4269                assert_eq!(client.overlay_rebuild_count.get(), 0);
4270            }
4271        }
4272    }
4273
4274    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4275        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4276            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4277            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4278            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4279        })))});
4280        let mut client =
4281            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4282        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();
4283        client.rebuild_overlay();
4284        client
4285            .mutate(vec![Mutation::Upsert {
4286                table: "tasks".into(),
4287                values: Map::from_iter([
4288                    ("id".into(), json!("offline")),
4289                    ("project_id".into(), json!("p1")),
4290                    ("title".into(), json!("pending edit")),
4291                ]),
4292                base_version: None,
4293            }])
4294            .unwrap();
4295        let image = Connection::open_in_memory().unwrap();
4296        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4297            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4298        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();
4299        image
4300            .execute(
4301                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4302                [rows],
4303            )
4304            .unwrap();
4305        client.overlay_rebuild_count.set(0);
4306        let table = client.schema.table("tasks").unwrap().clone();
4307        (client, image, table)
4308    }
4309
4310    #[test]
4311    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
4312        let (mut client, image, table) = unique_import_fixture(1);
4313        image
4314            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
4315            .unwrap();
4316        client
4317            .conn
4318            .execute_batch("PRAGMA max_page_count = 1")
4319            .unwrap();
4320        let effective = [("project_id".into(), vec!["p1".into()])];
4321        assert!(client
4322            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
4323            .is_err());
4324        let failure = client.storage_failure.borrow().clone().unwrap();
4325        assert_eq!(failure.code, Some("client.storage_full"));
4326        assert_eq!(failure.sqlite_code, Some(13));
4327        assert!(!failure.message.contains("rollback"));
4328        assert!(client.conn.is_autocommit());
4329        client
4330            .conn
4331            .execute_batch("PRAGMA max_page_count = 1073741823")
4332            .unwrap();
4333        assert!(matches!(
4334            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
4335            Ok(1)
4336        ));
4337        assert!(client.conn.is_autocommit());
4338    }
4339
4340    #[test]
4341    fn large_unique_import_copies_no_unrelated_tables() {
4342        let (mut client, image, table) = unique_import_fixture(10240);
4343        let pending = client.pending_commit_ids();
4344        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4345            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4346        assert!(matches!(
4347            client.apply_sqlite_image(
4348                &image,
4349                &table,
4350                true,
4351                &[("project_id".into(), vec!["p1".into()])],
4352                10240,
4353                7,
4354                "digest"
4355            ),
4356            Ok(10240)
4357        ));
4358        assert_eq!(client.overlay_rebuild_count.get(), 0);
4359        assert_eq!(client.pending_commit_ids(), pending);
4360        assert_eq!(
4361            client
4362                .query("SELECT count(*) AS n FROM tasks", &[])
4363                .unwrap()[0]["n"],
4364            10241
4365        );
4366        assert_eq!(
4367            client
4368                .query("SELECT count(*) AS n FROM tasks_fts", &[])
4369                .unwrap()[0]["n"],
4370            10241
4371        );
4372        assert_eq!(
4373            client
4374                .query("SELECT count(*) AS n FROM notes_fts", &[])
4375                .unwrap()[0]["n"],
4376            8192
4377        );
4378    }
4379
4380    #[test]
4381    #[ignore = "manual scaling benchmark; see bench/README.md"]
4382    fn benchmark_unique_image_import() {
4383        let mut samples = Vec::new();
4384        for rows in [10240, 51200, 102400] {
4385            for trial in 0..4 {
4386                let (mut client, image, table) = unique_import_fixture(rows);
4387                let start = std::time::Instant::now();
4388                assert!(
4389                    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)
4390                );
4391                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4392                assert_eq!(
4393                    client
4394                        .query("SELECT count(*) AS n FROM tasks", &[])
4395                        .unwrap()[0]["n"],
4396                    rows + 1
4397                );
4398                assert_eq!(
4399                    client
4400                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
4401                        .unwrap()[0]["n"],
4402                    rows + 1
4403                );
4404                assert_eq!(
4405                    client
4406                        .query("SELECT count(*) AS n FROM notes_fts", &[])
4407                        .unwrap()[0]["n"],
4408                    8192
4409                );
4410                assert_eq!(client.pending_commit_ids().len(), 1);
4411                if trial > 0 {
4412                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4413                }
4414            }
4415        }
4416        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});
4417        let output = serde_json::to_string_pretty(&artifact).unwrap();
4418        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4419            use std::io::Write;
4420            let mut file = std::fs::OpenOptions::new()
4421                .write(true)
4422                .create_new(true)
4423                .open(path)
4424                .unwrap();
4425            file.write_all(output.as_bytes()).unwrap();
4426        } else {
4427            println!("{output}");
4428        }
4429    }
4430
4431    #[test]
4432    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4433        let schema = json!({
4434            "version": 1,
4435            "tables": [{
4436                "name": "catalogue_codes",
4437                "primaryKey": "id",
4438                "columns": [
4439                    { "name": "id", "type": "string", "nullable": false },
4440                    { "name": "release_id", "type": "string", "nullable": false },
4441                    { "name": "code", "type": "string", "nullable": false },
4442                    { "name": "title", "type": "string", "nullable": false }
4443                ],
4444                "scopes": [{ "pattern": "release:{release_id}" }],
4445                "ftsIndexes": [{
4446                    "name": "catalogue_codes_fts",
4447                    "columns": ["code", "title"],
4448                    "tokenize": "unicode61 remove_diacritics 2"
4449                }]
4450            }]
4451        });
4452        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4453            .expect("FTS5 client");
4454        let insert_trigger: String = client
4455            .conn
4456            .query_row(
4457                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4458                [],
4459                |row| row.get(0),
4460            )
4461            .expect("insert trigger");
4462        let replace_guard: String = client
4463            .conn
4464            .query_row(
4465                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4466                [],
4467                |row| row.get(0),
4468            )
4469            .expect("replace guard");
4470        assert!(!insert_trigger.contains("DELETE FROM"));
4471        assert!(replace_guard.contains("BEFORE INSERT"));
4472        assert!(replace_guard.contains("WHEN EXISTS"));
4473        let search = |client: &SyncClient, query: &str| {
4474            client
4475                .query(
4476                    "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",
4477                    &[Value::from(query)],
4478                )
4479                .expect("FTS query")
4480        };
4481
4482        client
4483            .mutate(vec![Mutation::Upsert {
4484                table: "catalogue_codes".to_owned(),
4485                values: Map::from_iter([
4486                    ("id".to_owned(), Value::from("c1")),
4487                    ("release_id".to_owned(), Value::from("r1")),
4488                    ("code".to_owned(), Value::from("A01")),
4489                    ("title".to_owned(), Value::from("Cholera")),
4490                ]),
4491                base_version: None,
4492            }])
4493            .expect("insert code");
4494        assert_eq!(search(&client, "cholera").len(), 1);
4495        let prior = client
4496            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4497            .unwrap();
4498        client
4499            .conn
4500            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4501            .unwrap();
4502        client.create_synced_tables().unwrap();
4503        assert_eq!(
4504            client
4505                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4506                .unwrap(),
4507            prior
4508        );
4509        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();
4510        let rendered = serde_json::to_string(&plan).unwrap();
4511        assert!(rendered.contains("COVERING INDEX"));
4512        assert!(rendered.contains("INDEX 0:="));
4513        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4514        assert_eq!(search(&client, "cholera").len(), 1);
4515
4516        client
4517            .mutate(vec![Mutation::Upsert {
4518                table: "catalogue_codes".to_owned(),
4519                values: Map::from_iter([
4520                    ("id".to_owned(), Value::from("c1")),
4521                    ("release_id".to_owned(), Value::from("r1")),
4522                    ("code".to_owned(), Value::from("A01")),
4523                    ("title".to_owned(), Value::from("Enteric infection")),
4524                ]),
4525                base_version: None,
4526            }])
4527            .expect("update code");
4528        assert!(search(&client, "cholera").is_empty());
4529        assert_eq!(search(&client, "enteric").len(), 1);
4530
4531        client
4532            .mutate(vec![Mutation::Delete {
4533                table: "catalogue_codes".to_owned(),
4534                row_id: "c1".to_owned(),
4535                base_version: None,
4536            }])
4537            .expect("delete code");
4538        assert!(search(&client, "enteric").is_empty());
4539    }
4540
4541    #[test]
4542    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4543        let schema = json!({
4544            "version": 1,
4545            "tables": [{
4546                "name": "patient_notes",
4547                "primaryKey": "id",
4548                "columns": [
4549                    { "name": "id", "type": "string", "nullable": false },
4550                    { "name": "practice_id", "type": "string", "nullable": false },
4551                    { "name": "encryption_key_id", "type": "string", "nullable": false },
4552                    { "name": "title", "type": "string", "nullable": false }
4553                ],
4554                "scopes": [{ "pattern": "practice:{practice_id}" }],
4555                "ftsIndexes": [{
4556                    "name": "patient_notes_fts",
4557                    "columns": ["title"],
4558                    "tokenize": "unicode61 remove_diacritics 2"
4559                }]
4560            }]
4561        });
4562        let mut client = SyncClient::new(
4563            "local-purge-test".to_owned(),
4564            &schema,
4565            ClientLimits::default(),
4566        )
4567        .expect("local purge client");
4568        client
4569            .conn
4570            .execute(
4571                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4572                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4573                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4574                [],
4575            )
4576            .expect("seed base rows");
4577        client.overlay_dirty.set(true);
4578        client.rebuild_overlay_if_dirty();
4579
4580        let note = |id: &str, key_id: &str, title: &str| {
4581            Map::from_iter([
4582                ("id".to_owned(), Value::from(id)),
4583                ("practice_id".to_owned(), Value::from("practice-1")),
4584                ("encryption_key_id".to_owned(), Value::from(key_id)),
4585                ("title".to_owned(), Value::from(title)),
4586            ])
4587        };
4588        let doomed = client
4589            .mutate(vec![
4590                Mutation::Upsert {
4591                    table: "patient_notes".to_owned(),
4592                    values: note("target", "key-revoked", "Target changed"),
4593                    base_version: None,
4594                },
4595                Mutation::Upsert {
4596                    table: "patient_notes".to_owned(),
4597                    values: note("unrelated", "key-held", "Unrelated changed"),
4598                    base_version: None,
4599                },
4600            ])
4601            .expect("doomed commit");
4602        let kept = client
4603            .mutate(vec![Mutation::Upsert {
4604                table: "patient_notes".to_owned(),
4605                values: note("kept", "key-held", "Kept optimistic"),
4606                base_version: None,
4607            }])
4608            .expect("kept commit");
4609        client.drain_change_batches();
4610
4611        let input = LocalDataPurgeInput {
4612            purge_id: "purge-001".to_owned(),
4613            targets: vec![LocalDataPurgeTarget {
4614                table: "patient_notes".to_owned(),
4615                selectors: BTreeMap::from([(
4616                    "encryption_key_id".to_owned(),
4617                    vec!["key-revoked".to_owned()],
4618                )]),
4619            }],
4620        };
4621        assert_eq!(
4622            client.purge_local_data(&input).expect("apply purge"),
4623            LocalDataPurgeResult {
4624                already_applied: false,
4625                purged_rows: 1,
4626                dropped_commits: 1,
4627            }
4628        );
4629        let rows = client
4630            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4631            .expect("visible rows");
4632        assert_eq!(rows.len(), 2);
4633        assert_eq!(rows[0]["id"], "kept");
4634        assert_eq!(rows[1]["id"], "unrelated");
4635        assert_eq!(rows[1]["title"], "Unrelated original");
4636        let fts = client
4637            .query(
4638                "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'",
4639                &[],
4640            )
4641            .expect("fts query");
4642        assert!(fts.is_empty());
4643        assert_eq!(client.outbox.len(), 1);
4644        assert_eq!(client.outbox[0].client_commit_id, kept);
4645        let outcome = client
4646            .commit_outcome(&doomed)
4647            .expect("read doomed outcome")
4648            .expect("doomed outcome");
4649        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4650        match &outcome.results[0] {
4651            CommitOperationOutcome::Error { rejection } => {
4652                assert_eq!(rejection.code, "client.local_data_purged");
4653                assert_eq!(rejection.client_commit_id, doomed);
4654            }
4655            other => panic!("expected local purge rejection, got {other:?}"),
4656        }
4657        assert_eq!(client.drain_change_batches().len(), 1);
4658        assert_eq!(
4659            client.purge_local_data(&input).expect("retry purge"),
4660            LocalDataPurgeResult {
4661                already_applied: true,
4662                purged_rows: 0,
4663                dropped_commits: 0,
4664            }
4665        );
4666        let conflicting = LocalDataPurgeInput {
4667            purge_id: input.purge_id.clone(),
4668            targets: vec![LocalDataPurgeTarget {
4669                table: "patient_notes".to_owned(),
4670                selectors: BTreeMap::from([(
4671                    "encryption_key_id".to_owned(),
4672                    vec!["key-held".to_owned()],
4673                )]),
4674            }],
4675        };
4676        assert!(client
4677            .purge_local_data(&conflicting)
4678            .expect_err("id collision must fail")
4679            .contains("already used with a different plan"));
4680    }
4681}
4682
4683impl SubState {
4684    fn name(self) -> &'static str {
4685        match self {
4686            SubState::Active => "active",
4687            SubState::Revoked => "revoked",
4688            SubState::Failed => "failed",
4689        }
4690    }
4691
4692    fn parse(value: &str) -> Self {
4693        match value {
4694            "revoked" => Self::Revoked,
4695            "failed" => Self::Failed,
4696            _ => Self::Active,
4697        }
4698    }
4699}
4700
4701#[derive(Debug, Clone)]
4702struct Subscription {
4703    id: String,
4704    table: String,
4705    requested: Vec<(String, Vec<String>)>,
4706    params: Option<String>,
4707    cursor: i64,
4708    /// §4.7 resume token, round-tripped opaquely.
4709    bootstrap_state: Option<String>,
4710    state: SubState,
4711    reason_code: Option<String>,
4712    /// Last effective scopes echoed while active (§3.3: persisted for the
4713    /// purge contract; each active echo replaces it).
4714    effective: Option<Vec<(String, Vec<String>)>>,
4715    synced_once: bool,
4716}
4717
4718#[derive(Debug, Clone)]
4719struct OutboxOp {
4720    upsert: bool,
4721    table: String,
4722    row_id: String,
4723    base_version: Option<i64>,
4724    /// Schema-agnostic local form (§0): driver JSON values, encoded with
4725    /// the current codec at send time. A key's presence IS the §6.1 sparse
4726    /// presence set — both the overlay and the wire payload derive from it.
4727    values: Option<Map<String, Value>>,
4728}
4729
4730impl From<&OutboxOp> for CommitOperation {
4731    fn from(operation: &OutboxOp) -> Self {
4732        Self {
4733            table: operation.table.clone(),
4734            row_id: operation.row_id.clone(),
4735            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4736            base_version: operation.base_version,
4737            values: operation.values.clone(),
4738        }
4739    }
4740}
4741
4742#[derive(Debug, Clone)]
4743struct OutboxCommit {
4744    client_commit_id: String,
4745    ops: Vec<OutboxOp>,
4746}
4747
4748/// One commit's `(table, value keys)` per upsert operation — the classification
4749/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
4750/// can never disagree about which commits cannot re-encode.
4751fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4752    commit
4753        .ops
4754        .iter()
4755        .filter(|operation| operation.upsert)
4756        .map(|operation| {
4757            (
4758                operation.table.as_str(),
4759                operation
4760                    .values
4761                    .as_ref()
4762                    .map(|values| values.keys().map(String::as_str).collect())
4763                    .unwrap_or_default(),
4764            )
4765        })
4766        .collect()
4767}
4768
4769#[derive(Debug, Clone)]
4770struct CompiledLocalDataPurgeTarget {
4771    table: String,
4772    selectors: Vec<(String, Vec<String>)>,
4773}
4774
4775struct StoredCommitOutcomeRow {
4776    sequence: i64,
4777    client_commit_id: String,
4778    status: String,
4779    recorded_at_ms: i64,
4780    results_json: String,
4781    operations_json: Option<String>,
4782    resolution: String,
4783    resolved_at_ms: Option<i64>,
4784    replacement_client_commit_id: Option<String>,
4785}
4786
4787/// Section outcome distinguishing the §5.6 subscription-local fail-closed
4788/// path from a round-aborting failure (§1.4 rule 5).
4789enum SectionError {
4790    FailClosed,
4791    Abort(String, String),
4792}
4793
4794struct RequestMeta {
4795    pushed_ids: Vec<String>,
4796    /// Subscription id → the request carried `cursor < 0` and no resume
4797    /// token (§5.6 first-page detection: a *fresh* bootstrap).
4798    fresh: Vec<(String, bool)>,
4799    accept: u8,
4800    /// §6.1 splitBatch: outbox commits held back from THIS request because
4801    /// the running operation count reached the push cap — the next round
4802    /// pushes them (`sync_needed` stays set while any remain).
4803    deferred_commits: usize,
4804}
4805
4806#[derive(Default)]
4807struct ChangeAccumulator {
4808    /// None means table-wide; Some is the exact scoped domain.
4809    tables: BTreeMap<String, Option<BTreeSet<String>>>,
4810    windows: BTreeMap<(String, String), BTreeSet<String>>,
4811    status: bool,
4812    conflicts: bool,
4813    rejections: bool,
4814    outcomes: bool,
4815}
4816
4817impl ChangeAccumulator {
4818    fn table(&mut self, table: &str) {
4819        self.tables.insert(table.to_owned(), None);
4820    }
4821
4822    fn scope(&mut self, table: &str, key: String) {
4823        match self.tables.get_mut(table) {
4824            Some(None) => {}
4825            Some(Some(keys)) => {
4826                keys.insert(key);
4827            }
4828            None => {
4829                self.tables
4830                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
4831            }
4832        }
4833    }
4834
4835    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4836        self.windows
4837            .entry((base_key.to_owned(), table.to_owned()))
4838            .or_default()
4839            .insert(unit.to_owned());
4840    }
4841
4842    fn touched(&self) -> bool {
4843        !self.tables.is_empty()
4844            || !self.windows.is_empty()
4845            || self.status
4846            || self.conflicts
4847            || self.rejections
4848            || self.outcomes
4849    }
4850}
4851
4852/// §6.1: the server caps total operations per request (reference default
4853/// 500) and rejects the whole batch with `sync.too_many_operations`; the
4854/// client "splits and retries". Splitting happens at build time: commits are
4855/// included IN ORDER until the operation budget is spent, the rest wait for
4856/// the next round.
4857const PUSH_OPS_PER_REQUEST: usize = 500;
4858const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4859const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4860const MAX_LOCAL_PURGE_VALUES: usize = 128;
4861const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4862pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4863
4864pub struct SyncClient {
4865    #[cfg(feature = "bench-internals")]
4866    benchmark_phases: Recorder,
4867    conn: Connection,
4868    schema: ClientSchema,
4869    client_id: String,
4870    limits: ClientLimits,
4871    subs: Vec<Subscription>,
4872    outbox: Vec<OutboxCommit>,
4873    conflicts: Vec<ConflictRecord>,
4874    rejections: Vec<RejectionRecord>,
4875    schema_floor: Option<SchemaFloor>,
4876    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
4877    lease_state: Option<LeaseState>,
4878    /// §1.6: the schema-floor response stops syncing until an upgrade.
4879    stopped: bool,
4880    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
4881    upgrading: bool,
4882    /// §8.4 coalesced sync-needed signal.
4883    sync_needed: bool,
4884    /// §8.8: how sync rounds treat the realtime binding, and its exact
4885    /// availability state with the evidence behind a lost or refused one.
4886    realtime_policy: RealtimePolicy,
4887    realtime_state: RealtimeState,
4888    realtime_reason_code: Option<String>,
4889    realtime_retry_delay_ms: Option<u64>,
4890    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
4891    presence: HashMap<String, HashMap<String, PresencePeer>>,
4892    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
4893    /// host may pin it (conformance runs on a virtual clock).
4894    now_ms: Option<i64>,
4895    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
4896    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
4897    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
4898    encryption: crate::values::EncryptionConfig,
4899    /// Fail-closed host bootstrap gate. While set, command hosts permit only
4900    /// status/lifecycle inspection and an exact authorized local purge.
4901    security_preflight: bool,
4902    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
4903    /// means the feature is off; the descriptor write and the container orphan
4904    /// sweep still happen.
4905    previous_version: Option<PreviousVersionContextConfig>,
4906    /// Per-table primary-key upsert SQL, built once per (full table name) —
4907    /// the row write path runs per row during bootstrap (§5.6), so the SQL
4908    /// string (and, via `prepare_cached`, its compiled statement) is reused
4909    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
4910    /// schema reset (the column lists may have changed).
4911    insert_sql: RefCell<HashMap<String, String>>,
4912    /// §7.1 rebuild gate: true whenever the base tables or the outbox have
4913    /// diverged from the visible overlay since the last rebuild. Lets a
4914    /// no-op sync round skip the full base→visible copy.
4915    overlay_dirty: Cell<bool>,
4916    // First SQLite failure of the current operation, before savepoint cleanup.
4917    storage_failure: RefCell<Option<QueryReadFailure>>,
4918    /// Test-only structural performance signal: response processing must not
4919    /// turn a batch of acknowledgements into one full overlay rebuild each.
4920    #[cfg(test)]
4921    overlay_rebuild_count: Cell<usize>,
4922    /// Test-only structural performance signal: outcome retention is enforced
4923    /// once per response rather than once per acknowledgement.
4924    #[cfg(test)]
4925    outcome_prune_count: Cell<usize>,
4926    /// Exact observer-transaction output drained by command/FFI hosts.
4927    progress: ProgressObserver,
4928    change_queue: VecDeque<ClientChangeBatch>,
4929    sync_intent_queue: VecDeque<SyncIntent>,
4930    /// Explicit exponential retry policy for transient transport failures.
4931    retry_delay_ms: u64,
4932    /// §7.6: the background retry the current round scheduled, if any.
4933    round_retry_delay_ms: Option<u64>,
4934    last_round: Option<DiagnosticLastRound>,
4935    last_change: Option<DiagnosticLastChange>,
4936    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
4937    query_failures: Vec<DiagnosticQueryFailure>,
4938}
4939
4940pub(crate) fn quote_ident(name: &str) -> String {
4941    format!("\"{}\"", name.replace('"', "\"\""))
4942}
4943/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
4944/// previous-version module reads and writes the SAME records as the client
4945/// instead of growing a parallel meta layer.
4946pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
4947    conn.query_row(
4948        "SELECT value FROM _syncular_meta WHERE key = ?1",
4949        rusqlite::params![key],
4950        |row| row.get::<_, String>(0),
4951    )
4952    .ok()
4953}
4954
4955pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
4956    let _ = conn.execute(
4957        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4958        rusqlite::params![key, value],
4959    );
4960}
4961
4962pub(crate) fn meta_delete(conn: &Connection, key: &str) {
4963    let _ = conn.execute(
4964        "DELETE FROM _syncular_meta WHERE key = ?1",
4965        rusqlite::params![key],
4966    );
4967}
4968
4969/// Seek by the existing key while retaining the exact legacy text match.
4970fn row_id_predicate(table: &TableSchema) -> String {
4971    let key = quote_ident(&table.primary_key);
4972    let text_match = format!("CAST({key} AS TEXT) = ?1");
4973    match table.columns[table.pk_index].ty {
4974        ColumnType::String | ColumnType::Json => {
4975            format!("{key} = ?1 AND {text_match}")
4976        }
4977        // Native columns have no declared SQLite affinity. Match the stored
4978        // integer bind type explicitly. Unary + removes expression affinity so
4979        // SQLite can seek the typeless key; the text check preserves exact IDs.
4980        ColumnType::Integer | ColumnType::Boolean => {
4981            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4982        }
4983        // REAL-to-text conversion can round distinct values to the same text.
4984        // Narrowing those matches by numeric equality would change behavior.
4985        _ => text_match,
4986    }
4987}
4988
4989fn is_local_operation_code_like(value: &str) -> bool {
4990    let bytes = value.as_bytes();
4991    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4992        && bytes
4993            .iter()
4994            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4995}
4996
4997fn base_table(name: &str) -> String {
4998    quote_ident(&format!("_syncular_base_{name}"))
4999}
5000
5001/// §4.8: a stable, server-opaque key for a window base — table + variable +
5002/// canonical fixed scopes. Two `set_window` calls with the same base
5003/// address the same registry rows.
5004/// §4.8 deferred eviction record: (sub id, table, effective scope map).
5005type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5006
5007fn window_base_key(base: &WindowBase) -> String {
5008    format!(
5009        "{}\0{}\0{}",
5010        base.table,
5011        base.variable,
5012        canonical_scope_json(&base.fixed_scopes)
5013    )
5014}
5015
5016/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
5017fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5018    let mut scopes = base.fixed_scopes.clone();
5019    scopes.retain(|(k, _)| k != &base.variable);
5020    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5021    scopes
5022}
5023
5024/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
5025/// echoed not interpreted by the server, so the exact hash is client
5026/// convention; SHA-256 matches the SPEC's worked example.
5027fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5028    let canonical = canonical_scope_json(&unit_scopes(base, unit));
5029    let digest = Sha256::digest(canonical.as_bytes());
5030    let hex = bytes_to_hex(&digest);
5031    format!("w:{}:{}", base.table, &hex[..16])
5032}
5033
5034fn visible_table(name: &str) -> String {
5035    quote_ident(name)
5036}
5037
5038const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5039
5040/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
5041/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
5042fn is_synced_table_name(name: &str) -> bool {
5043    if name.starts_with("sqlite_") {
5044        return false;
5045    }
5046    if name.starts_with("_syncular_base_") {
5047        return true; // the base half of a synced table pair
5048    }
5049    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
5050    !name.starts_with("_syncular_")
5051}
5052
5053/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
5054fn blob_id_for(bytes: &[u8]) -> String {
5055    let digest = Sha256::digest(bytes);
5056    format!("sha256:{}", bytes_to_hex(&digest))
5057}
5058
5059/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
5060/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
5061/// per row on the §5.6 bootstrap path), scalars bind owned.
5062enum RowParam<'a> {
5063    Cell(&'a Option<ColumnValue>),
5064    Version(i64),
5065}
5066
5067impl rusqlite::ToSql for RowParam<'_> {
5068    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5069        Ok(match self {
5070            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5071            RowParam::Cell(cell) => match cell {
5072                None => ToSqlOutput::Owned(SqlValue::Null),
5073                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5074                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5075                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5076                Some(ColumnValue::Boolean(b)) => {
5077                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5078                }
5079                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5080                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5081                }
5082                // §5.10: crdt bytes store as BLOB, like bytes.
5083                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5084                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
5085                }
5086            },
5087        })
5088    }
5089}
5090
5091// Retain a typed primary key across the current import chunk without copying row payloads.
5092fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5093    match param.to_sql().map_err(|error| error.to_string())? {
5094        ToSqlOutput::Owned(value) => Ok(value),
5095        ToSqlOutput::Borrowed(value) => {
5096            SqlValue::try_from(value).map_err(|error| error.to_string())
5097        }
5098        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5099    }
5100}
5101
5102/// §5.3 image cell → bind parameter, strict per the declared column type
5103/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
5104/// nullable). Mismatches are image-producer violations. Returns the cell
5105/// borrowed when the stored representation already matches what the row
5106/// codec would write, or the normalized scalar (boolean → 0/1, float from
5107/// INTEGER → REAL) otherwise — the local write is byte-identical to the
5108/// old convert-then-insert path without allocating per cell.
5109fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5110    use ssp2::segment::ColumnType;
5111    let mismatch = || {
5112        Err(format!(
5113            "image column {:?} holds a value of the wrong type",
5114            column.name
5115        ))
5116    };
5117    match value {
5118        ValueRef::Null => {
5119            if !column.nullable {
5120                return Err(format!(
5121                    "image column {:?} is NULL but not nullable",
5122                    column.name
5123                ));
5124            }
5125            Ok(ToSqlOutput::Owned(SqlValue::Null))
5126        }
5127        ValueRef::Integer(i) => match column.ty {
5128            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5129            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5130            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5131            _ => mismatch(),
5132        },
5133        ValueRef::Real(_) => match column.ty {
5134            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5135            _ => mismatch(),
5136        },
5137        ValueRef::Text(t) => {
5138            std::str::from_utf8(t)
5139                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5140            match column.ty {
5141                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5142                    Ok(ToSqlOutput::Borrowed(value))
5143                }
5144                _ => mismatch(),
5145            }
5146        }
5147        ValueRef::Blob(_) => match column.ty {
5148            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
5149            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5150            _ => mismatch(),
5151        },
5152    }
5153}
5154
5155pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5156    use rusqlite::types::ValueRef;
5157    match value {
5158        ValueRef::Null => Value::Null,
5159        ValueRef::Integer(i) => match column.ty {
5160            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5161            ssp2::segment::ColumnType::Float => {
5162                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5163            }
5164            _ => Value::from(i),
5165        },
5166        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5167        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5168        ValueRef::Blob(b) => {
5169            let mut map = Map::new();
5170            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5171            Value::Object(map)
5172        }
5173    }
5174}
5175
5176/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
5177/// Objects are accepted in lossless `{"$bytes": hex}` and
5178/// `{"$bigint": decimal}` envelope forms.
5179fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5180    Ok(match value {
5181        Value::Null => SqlValue::Null,
5182        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5183        Value::Number(n) => {
5184            if let Some(i) = n.as_i64() {
5185                SqlValue::Integer(i)
5186            } else if let Some(f) = n.as_f64() {
5187                SqlValue::Real(f)
5188            } else {
5189                return Err(format!("query param number {n} is out of range"));
5190            }
5191        }
5192        Value::String(s) => SqlValue::Text(s.clone()),
5193        Value::Object(_) => {
5194            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5195                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5196            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5197                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5198                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5199                })?)
5200            } else {
5201                return Err(
5202                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5203                        .to_owned(),
5204                );
5205            }
5206        }
5207        Value::Array(_) => return Err("query array params are not supported".to_owned()),
5208    })
5209}
5210
5211/// Map a rusqlite value with no schema column to consult (arbitrary query
5212/// output): integers/reals/text pass through by stored affinity, blobs ride
5213/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
5214/// schema column type to recover booleans/floats/json.
5215fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5216    use rusqlite::types::ValueRef;
5217    match value {
5218        ValueRef::Null => Value::Null,
5219        ValueRef::Integer(i) => {
5220            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
5221            // ordinary UI-sized integers ergonomic and envelope only values
5222            // beyond JavaScript's safe range.
5223            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5224            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5225                Value::from(i)
5226            } else {
5227                let mut map = Map::new();
5228                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5229                Value::Object(map)
5230            }
5231        }
5232        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5233        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5234        ValueRef::Blob(b) => {
5235            let mut map = Map::new();
5236            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5237            Value::Object(map)
5238        }
5239    }
5240}
5241
5242/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
5243fn query_connection(
5244    conn: &Connection,
5245    sql: &str,
5246    params: &[QueryValue],
5247) -> Result<Vec<QueryRow>, QueryReadFailure> {
5248    crate::query_guard::assert_read_only_query(sql)?;
5249    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5250    let bound: Vec<SqlValue> = params
5251        .iter()
5252        .map(json_param_to_sql)
5253        .collect::<Result<_, _>>()?;
5254    let mut stmt = conn.prepare(&lowered_sql)?;
5255    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5256    let bound_refs: Vec<&dyn rusqlite::ToSql> =
5257        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5258    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5259    let mut out = Vec::new();
5260    while let Some(row) = sql_rows.next()? {
5261        let mut record = Map::new();
5262        for (i, name) in column_names.iter().enumerate() {
5263            let value = row.get_ref(i)?;
5264            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5265        }
5266        out.push(record);
5267    }
5268    Ok(out)
5269}
5270
5271fn persisted_window_state(
5272    conn: &Connection,
5273    base: &WindowBase,
5274) -> Result<WindowState, QueryReadFailure> {
5275    let mut stmt = conn.prepare(
5276        "SELECT windows.unit, subscriptions.state_json
5277           FROM _syncular_windows AS windows
5278           JOIN _syncular_subscriptions AS subscriptions
5279             ON subscriptions.id = windows.sub_id
5280          WHERE windows.base = ?1
5281          ORDER BY windows.unit ASC",
5282    )?;
5283    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5284        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5285    })?;
5286    let mut units = Vec::new();
5287    let mut pending = Vec::new();
5288    for row in rows {
5289        let (unit, raw) = row?;
5290        let state: Value = serde_json::from_str(&raw)
5291            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5292        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5293            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5294            || state
5295                .get("bootstrapState")
5296                .is_some_and(|value| !value.is_null());
5297        if is_pending {
5298            pending.push(unit.clone());
5299        }
5300        units.push(unit);
5301    }
5302    Ok(WindowState { units, pending })
5303}
5304
5305fn snapshot_connection(
5306    conn: &Connection,
5307    sql: &str,
5308    params: &[Value],
5309    coverage: &[WindowCoverage],
5310) -> Result<QuerySnapshot, QueryReadFailure> {
5311    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5312    let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
5313        let revision = conn
5314            .query_row(
5315                "SELECT value FROM _syncular_meta WHERE key = ?1",
5316                rusqlite::params![LOCAL_REVISION_KEY],
5317                |row| row.get::<_, String>(0),
5318            )
5319            .ok()
5320            .and_then(|value| value.parse::<u64>().ok())
5321            .unwrap_or(0);
5322        let rows = query_connection(conn, sql, params)?;
5323        let mut pending = Vec::new();
5324        let mut missing = Vec::new();
5325        for requested in coverage {
5326            let base_key = window_base_key(&requested.base);
5327            let state = persisted_window_state(conn, &requested.base)?;
5328            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5329                let reference = WindowUnitRef {
5330                    base_key: base_key.clone(),
5331                    unit: unit.clone(),
5332                };
5333                if !state.units.iter().any(|held| held == &unit) {
5334                    missing.push(reference);
5335                } else if state.pending.iter().any(|held| held == &unit) {
5336                    pending.push(reference);
5337                }
5338            }
5339        }
5340        Ok(QuerySnapshot {
5341            revision: revision.to_string(),
5342            rows,
5343            coverage: CoverageSnapshot {
5344                complete: pending.is_empty() && missing.is_empty(),
5345                pending,
5346                missing,
5347            },
5348        })
5349    })();
5350    match result {
5351        Ok(snapshot) => {
5352            conn.execute_batch("RELEASE syncular_snapshot_read")?;
5353            Ok(snapshot)
5354        }
5355        Err(mut error) => {
5356            if let Err(rollback) = conn
5357                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
5358            {
5359                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
5360            }
5361            Err(error)
5362        }
5363    }
5364}
5365
5366/// A long-lived read-only SQLite sidecar for latency-critical native views.
5367/// Network rounds stay serialized on the mutable core owner, while atomic
5368/// query snapshots use this independent connection and therefore never queue
5369/// behind HTTP/WebSocket latency.
5370pub struct FileQuerySnapshotReader {
5371    path: String,
5372    conn: Option<Connection>,
5373}
5374
5375impl FileQuerySnapshotReader {
5376    #[must_use]
5377    pub fn new(path: impl Into<String>) -> Self {
5378        Self {
5379            path: path.into(),
5380            conn: None,
5381        }
5382    }
5383
5384    fn connection(&mut self) -> Result<&Connection, String> {
5385        if self.conn.is_none() {
5386            let conn = Connection::open_with_flags(
5387                &self.path,
5388                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5389            )
5390            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5391            conn.busy_timeout(std::time::Duration::from_millis(250))
5392                .map_err(|error| error.to_string())?;
5393            self.conn = Some(conn);
5394        }
5395        self.conn
5396            .as_ref()
5397            .ok_or_else(|| "read sidecar connection missing".to_owned())
5398    }
5399
5400    /// Hosts report an owned failure, and the first success after it, to the
5401    /// owning core through [`SyncClient::record_query_read`] (§7.6).
5402    pub fn query_snapshot(
5403        &mut self,
5404        sql: &str,
5405        params: &[Value],
5406        coverage: &[WindowCoverage],
5407    ) -> Result<QuerySnapshot, QueryReadFailure> {
5408        snapshot_connection(self.connection()?, sql, params, coverage)
5409    }
5410}
5411
5412impl SyncClient {
5413    pub fn new_with_identity(
5414        client_id: Option<String>,
5415        schema_json: &Value,
5416        limits: ClientLimits,
5417    ) -> Result<Self, String> {
5418        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5419        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5420        Self::with_connection(resolved, schema_json, limits, conn)
5421    }
5422
5423    pub fn new(
5424        client_id: String,
5425        schema_json: &Value,
5426        limits: ClientLimits,
5427    ) -> Result<Self, String> {
5428        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5429        Self::with_connection(client_id, schema_json, limits, conn)
5430    }
5431
5432    /// Build a client backed by an on-disk SQLite database at `path` — the
5433    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
5434    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
5435    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
5436    /// of the command router's dependency set (the router only holds a path).
5437    pub fn open_path(
5438        client_id: String,
5439        schema_json: &Value,
5440        limits: ClientLimits,
5441        path: &str,
5442    ) -> Result<Self, String> {
5443        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5444        Self::with_connection(client_id, schema_json, limits, conn)
5445    }
5446
5447    pub fn open_path_with_identity(
5448        client_id: Option<String>,
5449        schema_json: &Value,
5450        limits: ClientLimits,
5451        path: &str,
5452    ) -> Result<Self, String> {
5453        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5454        // File-backed native clients use an independent read connection for
5455        // latency-critical snapshots. WAL is SQLite's intended reader/writer
5456        // concurrency mode: a view read never holds a rollback-journal lock
5457        // that delays the mutable client's next commit, and a short busy
5458        // timeout absorbs the tiny checkpoint/schema-lock windows.
5459        conn.busy_timeout(std::time::Duration::from_millis(250))
5460            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5461        conn.pragma_update(None, "journal_mode", "WAL")
5462            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5463        let persisted = conn
5464            .query_row(
5465                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5466                [],
5467                |row| row.get::<_, String>(0),
5468            )
5469            .ok();
5470        let resolved = persisted
5471            .clone()
5472            .or(client_id.clone())
5473            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5474        if let (Some(existing), Some(requested)) = (persisted, client_id) {
5475            if existing != requested {
5476                return Err(format!(
5477                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5478                ));
5479            }
5480        }
5481        Self::with_connection(resolved, schema_json, limits, conn)
5482    }
5483
5484    #[must_use]
5485    pub fn client_id(&self) -> &str {
5486        &self.client_id
5487    }
5488
5489    /// Build a client over a caller-supplied rusqlite connection — the seam a
5490    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
5491    /// with an on-disk database (`Connection::open(path)`) rather than the
5492    /// default `:memory:`. The connection MUST be fresh (no pre-existing
5493    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
5494    /// the same file across process restarts reuses the persisted rows.
5495    pub fn with_connection(
5496        client_id: String,
5497        schema_json: &Value,
5498        limits: ClientLimits,
5499        conn: Connection,
5500    ) -> Result<Self, String> {
5501        if limits.outcome_retention_max_entries == Some(0) {
5502            return Err(
5503                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5504            );
5505        }
5506        let schema = parse_schema_json(schema_json)?;
5507        // RFC 0005 D8: resolve the previous-version config before the opening
5508        // reset runs — the capture happens inside it. Bad bounds fail loud.
5509        let previous_version = match limits.previous_version_context {
5510            Some(config) => {
5511                config.validate()?;
5512                Some(config)
5513            }
5514            None => None,
5515        };
5516        let mut client = SyncClient {
5517            #[cfg(feature = "bench-internals")]
5518            benchmark_phases: Recorder::default(),
5519            conn,
5520            schema,
5521            client_id,
5522            limits,
5523            subs: Vec::new(),
5524            outbox: Vec::new(),
5525            conflicts: Vec::new(),
5526            rejections: Vec::new(),
5527            schema_floor: None,
5528            lease_state: None,
5529            stopped: false,
5530            upgrading: false,
5531            sync_needed: false,
5532            realtime_policy: RealtimePolicy::Optional,
5533            realtime_state: RealtimeState::Disconnected,
5534            realtime_reason_code: None,
5535            realtime_retry_delay_ms: None,
5536            presence: HashMap::new(),
5537            now_ms: None,
5538            encryption: crate::values::EncryptionConfig::default(),
5539            security_preflight: false,
5540            previous_version,
5541            insert_sql: RefCell::new(HashMap::new()),
5542            overlay_dirty: Cell::new(false),
5543            storage_failure: RefCell::new(None),
5544            #[cfg(test)]
5545            overlay_rebuild_count: Cell::new(0),
5546            #[cfg(test)]
5547            outcome_prune_count: Cell::new(0),
5548            progress: ProgressObserver::default(),
5549            change_queue: VecDeque::new(),
5550            sync_intent_queue: VecDeque::new(),
5551            retry_delay_ms: 250,
5552            round_retry_delay_ms: None,
5553            last_round: None,
5554            last_change: None,
5555            query_failures: Vec::new(),
5556        };
5557        // The row write path leans on the prepared-statement cache (two
5558        // insert statements per synced table, plus the bookkeeping
5559        // statements); size it so a multi-table schema never thrashes.
5560        client
5561            .conn
5562            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5563        // Protected bookkeeping must exist before inspecting the persisted
5564        // schema marker. New app indexes may reference columns that only
5565        // exist after a version-bump reset.
5566        client.create_bookkeeping_tables()?;
5567        match client.get_meta(CLIENT_ID_KEY) {
5568            Some(existing) if existing != client.client_id => {
5569                return Err(format!(
5570                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5571                    client.client_id
5572                ));
5573            }
5574            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5575            _ => {}
5576        }
5577        client.restore_persisted_state()?;
5578        let marker = client
5579            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5580            .and_then(|value| value.parse::<i32>().ok());
5581        match marker {
5582            None => {
5583                client.create_synced_tables()?;
5584                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5585                // D1: the descriptor is written beside every marker write.
5586                set_local_schema_descriptor(&client.conn, &client.schema);
5587            }
5588            Some(version) if version == client.schema.version => {
5589                client.create_synced_tables()?;
5590                // RFC 0005 D1: the same-version open backfills the descriptor
5591                // for a database first opened by an unaware binary, so the
5592                // NEXT bump can capture without needing a schema bump first.
5593                set_local_schema_descriptor(&client.conn, &client.schema);
5594            }
5595            Some(_) => client.run_schema_reset()?,
5596        }
5597        // RFC 0005 D9: the reset sweep cannot see a container whose capture
5598        // committed in its OWN file while the replica's savepoint never
5599        // released — a crash at that seam leaves a container beside a matching
5600        // OLD marker, and the next open at that version runs no reset. Discard
5601        // it before anything can read it. No-op when no container exists.
5602        client.reconcile_previous_version_at_boot()?;
5603        client.clear_satisfied_persisted_schema_floor();
5604        client.prune_unknown_subscriptions()?;
5605        if marker == Some(client.schema.version) && !client.outbox.is_empty() {
5606            // Reconstruct the visible optimistic overlay from the durable base
5607            // plus outbox instead of trusting a process-interrupted mirror.
5608            client.overlay_dirty.set(true);
5609            client.rebuild_overlay();
5610        }
5611        // Every persisted active subscription needs one catch-up pull on open:
5612        // realtime only covers changes after connection, while an idempotent
5613        // setWindow correctly creates no fresh command effect. Pending outbox
5614        // work has the same restart requirement. The core owns this intent so
5615        // native hosts never poll or require an application-issued sync().
5616        client.enqueue_startup_sync_if_needed()?;
5617        Ok(client)
5618    }
5619
5620    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
5621    /// against this instead of system time (conformance virtual clock).
5622    pub fn set_now_ms(&mut self, now_ms: i64) {
5623        self.now_ms = Some(now_ms);
5624    }
5625
5626    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
5627    /// The command router parses these from the `create` command's
5628    /// `encryption` config (keys as `{$bytes: hex}`).
5629    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5630        self.encryption = encryption;
5631    }
5632
5633    #[must_use]
5634    pub fn security_lifecycle(&self) -> &'static str {
5635        if self.security_preflight {
5636            "preflight"
5637        } else {
5638            "active"
5639        }
5640    }
5641
5642    #[must_use]
5643    pub fn security_preflight(&self) -> bool {
5644        self.security_preflight
5645    }
5646
5647    /// Quarantine this replica: enter the fail-closed gate, release all
5648    /// core-owned key material, and record the gate in the database so
5649    /// reopening re-enters preflight until `activate_security` clears it.
5650    ///
5651    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
5652    /// which must NOT leave that durable mark.
5653    pub fn begin_security_preflight(&mut self) {
5654        self.seal_security_on_teardown();
5655        // Persist the quarantine so it survives handle teardown and restart:
5656        // reopening this replica re-enters preflight until activation clears it.
5657        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5658    }
5659
5660    /// Release core-owned key material as a host tears the client down,
5661    /// WITHOUT recording a quarantine.
5662    ///
5663    /// Shutting an activated client down is not a quarantine event. Persisting
5664    /// the gate here marks every cleanly closed replica as pending, and the
5665    /// next plain `create` is then refused permanently: the reopen path
5666    /// restores the flag from the marker and the create guard rejects it. The
5667    /// in-memory flag still closes the gate for anything still holding this
5668    /// instance, which is all a teardown barrier needs.
5669    pub fn seal_security_on_teardown(&mut self) {
5670        self.security_preflight = true;
5671        self.encryption = crate::values::EncryptionConfig::default();
5672        self.sync_intent_queue.clear();
5673    }
5674
5675    /// Install the post-authentication keyring and release the host loop.
5676    pub fn activate_security(
5677        &mut self,
5678        encryption: crate::values::EncryptionConfig,
5679    ) -> Result<(), String> {
5680        if !self.security_preflight {
5681            return Err(
5682                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5683            );
5684        }
5685        self.enqueue_startup_sync_if_needed()?;
5686        self.encryption = encryption;
5687        self.security_preflight = false;
5688        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5689        Ok(())
5690    }
5691
5692    fn clock_now_ms(&self) -> i64 {
5693        self.now_ms.unwrap_or_else(|| {
5694            std::time::SystemTime::now()
5695                .duration_since(std::time::UNIX_EPOCH)
5696                .map(|d| d.as_millis() as i64)
5697                .unwrap_or(0)
5698        })
5699    }
5700
5701    fn create_bookkeeping_tables(&self) -> Result<(), String> {
5702        // Durable client bookkeeping (outbox + subscription + meta).
5703        self.conn
5704            .execute_batch(
5705                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5706                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5707                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5708                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5709                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5710                   client_commit_id TEXT NOT NULL UNIQUE,
5711                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5712                   recorded_at_ms INTEGER NOT NULL,
5713                   results_json TEXT NOT NULL,
5714                   operations_json TEXT,
5715                   resolution TEXT NOT NULL DEFAULT 'active'
5716                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5717                   resolved_at_ms INTEGER,
5718                   replacement_client_commit_id TEXT);
5719                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5720                   ON _syncular_commit_outcomes(resolution, seq);
5721                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5722                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5723                 CREATE TABLE IF NOT EXISTS _syncular_meta (
5724                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
5725                 CREATE TABLE IF NOT EXISTS _syncular_windows (
5726                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5727                   PRIMARY KEY (base, unit));
5728                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5729                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5730                   effective_scopes TEXT NOT NULL);
5731                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5732                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5733                   PRIMARY KEY(commit_id, blob_id));
5734                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5735                   ON _syncular_blob_commit_refs(blob_id);",
5736            )
5737            .map_err(|e| e.to_string())?;
5738        // Migrate an outcome journal created before failed aggregate
5739        // envelopes were retained. Historical rows intentionally stay NULL.
5740        let _ = self
5741            .conn
5742            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5743        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5744            self.set_meta(LOCAL_REVISION_KEY, "0");
5745        }
5746        // §5.9.7 blob cache. Mutable upload state stays off the body row so
5747        // completing a large upload does not rewrite its BLOB record.
5748        if self.schema_has_blobs() {
5749            let exists = self
5750                .conn
5751                .query_row(
5752                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5753                    [],
5754                    |row| row.get::<_, bool>(0),
5755                )
5756                .map_err(|e| e.to_string())?;
5757            if exists {
5758                let mut statement = self
5759                    .conn
5760                    .prepare("PRAGMA table_info(_syncular_blobs)")
5761                    .map_err(|e| e.to_string())?;
5762                let mut columns = statement
5763                    .query_map([], |row| row.get::<_, String>(1))
5764                    .map_err(|e| e.to_string())?
5765                    .collect::<Result<Vec<_>, _>>()
5766                    .map_err(|e| e.to_string())?;
5767                columns.sort();
5768                let mut expected = vec![
5769                    "blob_id",
5770                    "byte_length",
5771                    "bytes",
5772                    "created_at_ms",
5773                    "media_type",
5774                ];
5775                expected.sort_unstable();
5776                if columns != expected {
5777                    return Err(
5778                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5779                    );
5780                }
5781            } else {
5782                self.conn
5783                    .execute_batch(
5784                        "CREATE TABLE _syncular_blobs(
5785                           blob_id TEXT PRIMARY KEY,
5786                           bytes BLOB NOT NULL,
5787                           byte_length INTEGER NOT NULL,
5788                           media_type TEXT,
5789                           created_at_ms INTEGER NOT NULL);",
5790                    )
5791                    .map_err(|e| e.to_string())?;
5792            }
5793            self.conn
5794                .execute_batch(
5795                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5796                       blob_id TEXT PRIMARY KEY,
5797                       media_type TEXT,
5798                       created_at_ms INTEGER NOT NULL);",
5799                )
5800                .map_err(|e| e.to_string())?;
5801        }
5802        Ok(())
5803    }
5804
5805    /// True iff any synced table declares a `blob_ref` column (§5.9).
5806    fn schema_has_blobs(&self) -> bool {
5807        self.schema
5808            .tables
5809            .iter()
5810            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5811    }
5812
5813    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
5814
5815    fn get_meta(&self, key: &str) -> Option<String> {
5816        meta_get(&self.conn, key)
5817    }
5818
5819    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5820        self.conn
5821            .query_row(
5822                "SELECT value FROM _syncular_meta WHERE key = ?1",
5823                rusqlite::params![key],
5824                |row| row.get::<_, String>(0),
5825            )
5826            .optional()
5827            .map_err(|_| {
5828                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5829            })
5830    }
5831
5832    fn set_meta(&self, key: &str, value: &str) {
5833        meta_set(&self.conn, key, value);
5834    }
5835
5836    fn delete_meta(&self, key: &str) {
5837        meta_delete(&self.conn, key);
5838    }
5839
5840    fn restore_persisted_state(&mut self) -> Result<(), String> {
5841        self.subs = {
5842            let mut stmt = self
5843                .conn
5844                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5845                .map_err(|error| error.to_string())?;
5846            let rows = stmt
5847                .query_map([], |row| {
5848                    Ok((
5849                        row.get::<_, String>(0)?,
5850                        row.get::<_, String>(1)?,
5851                        row.get::<_, String>(2)?,
5852                    ))
5853                })
5854                .map_err(|error| error.to_string())?;
5855            let mut subscriptions = Vec::new();
5856            for row in rows {
5857                let (id, table, raw) = row.map_err(|error| error.to_string())?;
5858                let state: Value = serde_json::from_str(&raw)
5859                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5860                let requested = json_to_scope_map(
5861                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
5862                )?;
5863                let effective = state
5864                    .get("effectiveScopes")
5865                    .filter(|value| !value.is_null())
5866                    .map(json_to_scope_map)
5867                    .transpose()?;
5868                subscriptions.push(Subscription {
5869                    id,
5870                    table,
5871                    requested,
5872                    params: state
5873                        .get("params")
5874                        .and_then(Value::as_str)
5875                        .map(str::to_owned),
5876                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5877                    bootstrap_state: state
5878                        .get("bootstrapState")
5879                        .and_then(Value::as_str)
5880                        .map(str::to_owned),
5881                    state: SubState::parse(
5882                        state
5883                            .get("status")
5884                            .and_then(Value::as_str)
5885                            .unwrap_or("active"),
5886                    ),
5887                    reason_code: state
5888                        .get("reasonCode")
5889                        .and_then(Value::as_str)
5890                        .map(str::to_owned),
5891                    effective,
5892                    synced_once: state
5893                        .get("syncedOnce")
5894                        .and_then(Value::as_bool)
5895                        .unwrap_or(false),
5896                });
5897            }
5898            subscriptions
5899        };
5900
5901        self.outbox = {
5902            let mut stmt = self
5903                .conn
5904                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5905                .map_err(|error| error.to_string())?;
5906            let rows = stmt
5907                .query_map([], |row| {
5908                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5909                })
5910                .map_err(|error| error.to_string())?;
5911            let mut commits = Vec::new();
5912            for row in rows {
5913                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5914                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5915                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
5916                })?;
5917                let mut ops = Vec::with_capacity(entries.len());
5918                for entry in entries {
5919                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5920                    ops.push(OutboxOp {
5921                        upsert: op == "upsert",
5922                        table: entry
5923                            .get("table")
5924                            .and_then(Value::as_str)
5925                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5926                            .to_owned(),
5927                        row_id: entry
5928                            .get("rowId")
5929                            .and_then(Value::as_str)
5930                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5931                            .to_owned(),
5932                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
5933                        // A legacy entry's `changedFields` key is ignored:
5934                        // the values map is the presence set (§6.1).
5935                        values: entry.get("values").and_then(Value::as_object).cloned(),
5936                    });
5937                }
5938                commits.push(OutboxCommit {
5939                    client_commit_id,
5940                    ops,
5941                });
5942            }
5943            commits
5944        };
5945
5946        self.prune_commit_outcomes()?;
5947        let active = self.commit_outcomes(CommitOutcomeQuery {
5948            active_only: true,
5949            ..CommitOutcomeQuery::default()
5950        })?;
5951        self.conflicts = active
5952            .iter()
5953            .flat_map(|outcome| outcome.results.iter())
5954            .filter_map(|result| match result {
5955                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5956                _ => None,
5957            })
5958            .collect();
5959        self.rejections = active
5960            .iter()
5961            .flat_map(|outcome| outcome.results.iter())
5962            .filter_map(|result| match result {
5963                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5964                _ => None,
5965            })
5966            .collect();
5967
5968        self.lease_state = self
5969            .get_meta(LEASE_STATE_KEY)
5970            .map(|raw| serde_json::from_str(&raw))
5971            .transpose()
5972            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5973        self.schema_floor = self
5974            .get_meta(SCHEMA_FLOOR_KEY)
5975            .map(|raw| serde_json::from_str(&raw))
5976            .transpose()
5977            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5978        self.stopped = self.schema_floor.is_some();
5979        // A replica left in unactivated preflight reopens gated: the quarantine
5980        // decision persists with the data across handle teardown and restart.
5981        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5982            self.security_preflight = true;
5983        }
5984        Ok(())
5985    }
5986
5987    /// Remove registrations for tables the running schema no longer knows.
5988    /// Otherwise the server rejects every pull with `sync.unknown_table`.
5989    fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5990        let valid_tables: BTreeSet<String> = self
5991            .schema
5992            .tables
5993            .iter()
5994            .map(|table| table.name.clone())
5995            .collect();
5996        let stale_ids: Vec<String> = self
5997            .subs
5998            .iter()
5999            .filter(|sub| !valid_tables.contains(&sub.table))
6000            .map(|sub| sub.id.clone())
6001            .collect();
6002        for id in &stale_ids {
6003            self.conn
6004                .execute(
6005                    "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6006                    rusqlite::params![id],
6007                )
6008                .map_err(|error| error.to_string())?;
6009            self.conn
6010                .execute(
6011                    "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6012                    rusqlite::params![id],
6013                )
6014                .map_err(|error| error.to_string())?;
6015            self.conn
6016                .execute(
6017                    "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6018                    rusqlite::params![id],
6019                )
6020                .map_err(|error| error.to_string())?;
6021        }
6022        self.subs.retain(|sub| valid_tables.contains(&sub.table));
6023        Ok(())
6024    }
6025
6026    #[must_use]
6027    pub fn local_revision(&self) -> u64 {
6028        self.get_meta(LOCAL_REVISION_KEY)
6029            .and_then(|value| value.parse().ok())
6030            .unwrap_or(0)
6031    }
6032
6033    #[must_use]
6034    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
6035        SyncStatusSnapshot {
6036            current_schema_version: self.schema.version,
6037            outbox: self.outbox.len(),
6038            upgrading: self.upgrading,
6039            lease_state: self.lease_state.clone(),
6040            schema_floor: self.schema_floor.clone(),
6041            sync_needed: self.sync_needed,
6042            previous_version_context: self.previous_version_status(),
6043        }
6044    }
6045
6046    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
6047    /// rollback precondition), read without creating the file.
6048    fn previous_version_status(&self) -> PreviousVersionStatus {
6049        let Some(replica_path) = self.previous_version_replica_path() else {
6050            return PreviousVersionStatus {
6051                present: false,
6052                created_at_ms: None,
6053            };
6054        };
6055        match crate::previous_version::read_previous_version_record(&replica_path) {
6056            Ok(Some(record)) => PreviousVersionStatus {
6057                present: true,
6058                created_at_ms: Some(record.created_at_ms),
6059            },
6060            _ => PreviousVersionStatus {
6061                present: std::path::Path::new(
6062                    &crate::previous_version::previous_version_container_path(&replica_path),
6063                )
6064                .exists(),
6065                created_at_ms: None,
6066            },
6067        }
6068    }
6069
6070    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
6071    /// is always `previousVersion`; the container never makes
6072    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
6073    /// revocation, coverage completion and the TTL DISCARD the container and
6074    /// close the read with their own reason.
6075    pub fn previous_version_snapshot(
6076        &self,
6077        spec: &PreviousVersionReadSpec,
6078    ) -> Result<PreviousVersionSnapshot, String> {
6079        let now_ms = self.clock_now_ms();
6080        crate::previous_version::previous_version_snapshot(
6081            &self.conn,
6082            self.previous_version_replica_path().as_deref(),
6083            self.previous_version.as_ref(),
6084            PreviousVersionLifecycle {
6085                lease_inactive: self.previous_version_lease_inactive(now_ms),
6086                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6087                coverage_complete: self.previous_version_coverage_complete(),
6088            },
6089            now_ms,
6090            self.schema.version,
6091            spec,
6092        )
6093    }
6094
6095    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
6096    #[must_use]
6097    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6098        crate::previous_version::stored_previous_version_audit(&self.conn)
6099    }
6100
6101    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
6102    /// and both metadata records, and report whether anything was present.
6103    /// Idempotent: absence is a successful no-op.
6104    pub fn previous_version_discard(
6105        &self,
6106    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6107        self.drop_previous_version()
6108    }
6109
6110    /// The module's discard has no failure path (it removes the file even when
6111    /// the container cannot be opened), so the only error a caller could ever
6112    /// see is a bug; report absence rather than claiming a discard happened.
6113    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6114        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6115            present: false,
6116            discarded: false,
6117        };
6118        let Some(replica_path) = self.previous_version_replica_path() else {
6119            return absent;
6120        };
6121        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6122            .unwrap_or(absent)
6123    }
6124
6125    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
6126    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6127        let Some(lease) = &self.lease_state else {
6128            return false;
6129        };
6130        if lease.error_code.is_some() {
6131            return true;
6132        }
6133        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6134    }
6135
6136    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
6137    /// has `cursor >= 0` and no resume token. An empty active set is not
6138    /// completion (mirrors the TS predicate exactly).
6139    fn previous_version_coverage_complete(&self) -> bool {
6140        let active = self
6141            .subs
6142            .iter()
6143            .filter(|sub| sub.state == SubState::Active)
6144            .collect::<Vec<_>>();
6145        if active.is_empty() {
6146            return false;
6147        }
6148        active
6149            .iter()
6150            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6151    }
6152
6153    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
6154    /// apply. A container that no longer has a reason to exist is discarded so
6155    /// the rows do not outlive the window they were kept for.
6156    fn previous_version_lifetime_check(&self) {
6157        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6158            return;
6159        };
6160        let now_ms = self.clock_now_ms();
6161        if self.previous_version_lease_inactive(now_ms) {
6162            let _ = self.drop_previous_version();
6163            return;
6164        }
6165        let Some(replica_path) = self.previous_version_replica_path() else {
6166            return;
6167        };
6168        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6169        else {
6170            return;
6171        };
6172        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6173            let _ = self.drop_previous_version();
6174            return;
6175        }
6176        if now_ms - record.created_at_ms > config.max_age_ms {
6177            let _ = self.drop_previous_version();
6178            return;
6179        }
6180        if self.previous_version_coverage_complete() {
6181            let _ = self.drop_previous_version();
6182        }
6183    }
6184
6185    pub fn diagnostics_snapshot(
6186        &self,
6187        request: &ClientDiagnosticsRequest,
6188    ) -> Result<ClientDiagnosticsSnapshot, String> {
6189        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6190            return Err(format!(
6191                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6192            ));
6193        }
6194        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6195        for sub in &self.subs {
6196            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6197            let complete =
6198                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6199            let state = match sub.state {
6200                SubState::Revoked => "revoked",
6201                SubState::Failed => "failed",
6202                SubState::Active if reset => "reset",
6203                SubState::Active if complete => "complete",
6204                SubState::Active => "bootstrapping",
6205            };
6206            subscriptions.insert(
6207                sub.id.clone(),
6208                DiagnosticSubscription {
6209                    id: sub.id.clone(),
6210                    table: sub.table.clone(),
6211                    state: state.to_owned(),
6212                    complete,
6213                    cursor: Some(sub.cursor),
6214                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6215                },
6216            );
6217        }
6218        for expected in &request.expected_subscriptions {
6219            if expected.id.is_empty() || expected.table.is_empty() {
6220                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6221            }
6222            if subscriptions
6223                .get(&expected.id)
6224                .is_some_and(|registered| registered.table != expected.table)
6225            {
6226                subscriptions.insert(
6227                    expected.id.clone(),
6228                    DiagnosticSubscription {
6229                        id: expected.id.clone(),
6230                        table: expected.table.clone(),
6231                        state: "failed".to_owned(),
6232                        complete: false,
6233                        cursor: None,
6234                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6235                    },
6236                );
6237            } else {
6238                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6239                    DiagnosticSubscription {
6240                        id: expected.id.clone(),
6241                        table: expected.table.clone(),
6242                        state: "unregistered".to_owned(),
6243                        complete: false,
6244                        cursor: None,
6245                        reason_code: None,
6246                    }
6247                });
6248            }
6249        }
6250        let mut ordered_subscriptions = Vec::new();
6251        let mut included = BTreeSet::new();
6252        for expected in &request.expected_subscriptions {
6253            if included.insert(expected.id.clone()) {
6254                if let Some(subscription) = subscriptions.get(&expected.id) {
6255                    ordered_subscriptions.push(subscription.clone());
6256                }
6257            }
6258        }
6259        for (id, subscription) in &subscriptions {
6260            if included.insert(id.clone()) {
6261                ordered_subscriptions.push(subscription.clone());
6262            }
6263        }
6264        let subscriptions_truncated =
6265            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6266        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6267        let captured_at_ms = self.clock_now_ms();
6268        let lease = if let Some(error_code) = self
6269            .lease_state
6270            .as_ref()
6271            .and_then(|state| state.error_code.clone())
6272        {
6273            ClientDiagnosticsLease {
6274                state: "stopped".to_owned(),
6275                expires_at_ms: self
6276                    .lease_state
6277                    .as_ref()
6278                    .and_then(|state| state.expires_at_ms),
6279                error_code: Some(Self::diagnostic_code(&error_code)),
6280            }
6281        } else if let Some(expires_at_ms) = self
6282            .lease_state
6283            .as_ref()
6284            .and_then(|state| state.expires_at_ms)
6285        {
6286            ClientDiagnosticsLease {
6287                state: if expires_at_ms <= captured_at_ms {
6288                    "expired".to_owned()
6289                } else {
6290                    "active".to_owned()
6291                },
6292                expires_at_ms: Some(expires_at_ms),
6293                error_code: None,
6294            }
6295        } else {
6296            ClientDiagnosticsLease {
6297                state: "none".to_owned(),
6298                expires_at_ms: None,
6299                error_code: None,
6300            }
6301        };
6302        let connectivity = match self.last_round.as_ref() {
6303            Some(round) if round.status == "succeeded" => "online",
6304            Some(round)
6305                if round.status == "failed"
6306                    && round
6307                        .error_code
6308                        .as_deref()
6309                        .is_some_and(Self::retryable_transport_code) =>
6310            {
6311                "offline"
6312            }
6313            _ => "unknown",
6314        };
6315        Ok(ClientDiagnosticsSnapshot {
6316            version: CLIENT_DIAGNOSTICS_VERSION,
6317            captured_at_ms,
6318            host: ClientDiagnosticsHost {
6319                kind: "direct".to_owned(),
6320                role: "single".to_owned(),
6321                connectivity: connectivity.to_owned(),
6322                realtime: self.realtime_state,
6323                realtime_policy: self.realtime_policy,
6324                realtime_reason_code: self.realtime_reason_code.clone(),
6325                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6326            },
6327            security_lifecycle: self.security_lifecycle().to_owned(),
6328            schema: ClientDiagnosticsSchema {
6329                current_version: self.schema.version,
6330                upgrading: self.upgrading,
6331                required_version: self
6332                    .schema_floor
6333                    .as_ref()
6334                    .and_then(|floor| floor.required_schema_version),
6335                latest_version: self
6336                    .schema_floor
6337                    .as_ref()
6338                    .and_then(|floor| floor.latest_schema_version),
6339            },
6340            replica: ClientDiagnosticsReplica {
6341                local_revision: self.local_revision().to_string(),
6342                sync_needed: self.sync_needed,
6343                pending_outbox: self.outbox.len(),
6344            },
6345            lease,
6346            subscriptions: ordered_subscriptions,
6347            subscriptions_truncated,
6348            last_round: self.last_round.clone(),
6349            last_change: self.last_change.clone(),
6350            storage: self.diagnostics_storage(),
6351            query_failures: self.query_failures.clone(),
6352        })
6353    }
6354
6355    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6356        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6357            let page_count: i64 = self
6358                .conn
6359                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6360            let page_size: i64 = self
6361                .conn
6362                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6363            // Reuse compiled aggregates; every invocation still reads current storage.
6364            let outbox_bytes: i64 = self
6365                .conn
6366                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6367                .query_row([], |row| row.get(0))?;
6368            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6369                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6370            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6371            let blob_bytes = if self.schema_has_blobs() {
6372                self.conn
6373                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6374                    .query_row([], |row| row.get(0))?
6375            } else {
6376                0
6377            };
6378            let pressure = self
6379                .limits
6380                .blob_cache_max_bytes
6381                .is_some_and(|limit| blob_bytes > limit);
6382            Ok(ClientDiagnosticsStorage {
6383                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6384                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6385                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6386                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6387                retained_outcome_entries: Some(outcome_entries.max(0)),
6388                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6389                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6390            })
6391        };
6392        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6393            status: "unreadable".to_owned(),
6394            database_bytes_approx: None,
6395            pending_outbox_bytes_approx: None,
6396            retained_outcome_bytes_approx: None,
6397            retained_outcome_entries: None,
6398            blob_cache_bytes_approx: None,
6399            pressure_reason_code: None,
6400        })
6401    }
6402
6403    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6404        self.change_queue.drain(..).collect()
6405    }
6406
6407    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6408        self.sync_intent_queue.drain(..).collect()
6409    }
6410
6411    fn schedule_background_retry(&mut self) -> u64 {
6412        let delay_ms = self.retry_delay_ms;
6413        self.sync_intent_queue
6414            .push_back(SyncIntent::Background { delay_ms });
6415        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6416        self.round_retry_delay_ms = Some(delay_ms);
6417        delay_ms
6418    }
6419
6420    fn reset_background_retry(&mut self) {
6421        self.retry_delay_ms = 250;
6422    }
6423
6424    fn retryable_transport_code(code: &str) -> bool {
6425        code == "transport.failed"
6426            || code == "transport.unavailable"
6427            || code == "sync.transport_failed"
6428    }
6429
6430    /// A failed round worth a background retry: a transport failure, or a
6431    /// §10.2 code the catalog marks retryable (clients may hardcode the
6432    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
6433    /// on the server's `retryable` flag.
6434    fn retryable_failure_code(code: &str) -> bool {
6435        Self::retryable_transport_code(code)
6436            || matches!(
6437                code,
6438                "sync.auth_required"
6439                    | "sync.auth_lease_required"
6440                    | "sync.auth_lease_revoked"
6441                    | "sync.internal_error"
6442                    | "sync.idempotency_cache_miss"
6443                    | "sync.schema_not_ready"
6444                    | "sync.segment_expired"
6445                    | "sync.rate_limited"
6446                    | "sync.websocket_connection_limit"
6447            )
6448    }
6449
6450    fn diagnostic_code(code: &str) -> String {
6451        let valid = !code.is_empty()
6452            && code.len() <= 96
6453            && code.contains('.')
6454            && code
6455                .bytes()
6456                .next()
6457                .is_some_and(|byte| byte.is_ascii_lowercase())
6458            && code.bytes().all(|byte| {
6459                byte.is_ascii_lowercase()
6460                    || byte.is_ascii_digit()
6461                    || matches!(byte, b'.' | b'_' | b'-')
6462            });
6463        if valid {
6464            code.to_owned()
6465        } else {
6466            "client.unknown_failure".to_owned()
6467        }
6468    }
6469
6470    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6471        if self.sync_needed != value {
6472            if self.begin_observation("syncular_status").is_ok() {
6473                self.sync_needed = value;
6474                let batch = ChangeAccumulator {
6475                    status: true,
6476                    ..ChangeAccumulator::default()
6477                };
6478                if self.finish_observation("syncular_status", batch).is_err() {
6479                    self.rollback_observation("syncular_status");
6480                }
6481            } else {
6482                self.sync_needed = value;
6483            }
6484        }
6485        if value && interactive {
6486            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6487        }
6488    }
6489
6490    fn sqlite_failure(
6491        storage_failure: &RefCell<Option<QueryReadFailure>>,
6492        error: rusqlite::Error,
6493    ) -> String {
6494        let message = error.to_string();
6495        let failure = QueryReadFailure::from(error);
6496        let code = failure.code;
6497        if code.is_some() && storage_failure.borrow().is_none() {
6498            *storage_failure.borrow_mut() = Some(failure);
6499        }
6500        match code {
6501            Some(code) => format!("{code}: {message}"),
6502            None => message,
6503        }
6504    }
6505
6506    fn begin_observation(&self, name: &str) -> Result<(), String> {
6507        self.conn
6508            .execute_batch(&format!("SAVEPOINT {name}"))
6509            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
6510    }
6511
6512    fn rollback_observation(&self, name: &str) {
6513        if let Err(error) = self
6514            .conn
6515            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
6516        {
6517            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
6518                if failure.rollback_failure.is_none() {
6519                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
6520                }
6521            }
6522        }
6523    }
6524
6525    fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
6526        #[cfg(feature = "bench-internals")]
6527        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6528        if !batch.touched() {
6529            self.conn
6530                .execute_batch(&format!("RELEASE {name}"))
6531                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6532            return Ok(());
6533        }
6534        let revision = self
6535            .local_revision()
6536            .checked_add(1)
6537            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6538        self.conn
6539            .execute(
6540                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6541                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6542            )
6543            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6544        let status = batch.status.then(|| self.status_snapshot());
6545        let event = ClientChangeBatch {
6546            revision: revision.to_string(),
6547            tables: batch
6548                .tables
6549                .into_iter()
6550                .map(|(table, scope_keys)| TableChange {
6551                    table,
6552                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6553                })
6554                .collect(),
6555            windows: batch
6556                .windows
6557                .into_iter()
6558                .map(|((base_key, table), units)| WindowChange {
6559                    base_key,
6560                    table,
6561                    units: units.into_iter().collect(),
6562                })
6563                .collect(),
6564            status,
6565            conflicts_changed: batch.conflicts,
6566            rejections_changed: batch.rejections,
6567            outcomes_changed: batch.outcomes,
6568        };
6569        self.conn
6570            .execute_batch(&format!("RELEASE {name}"))
6571            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6572        let mut diagnostic_tables = event
6573            .tables
6574            .iter()
6575            .map(|entry| entry.table.clone())
6576            .collect::<BTreeSet<_>>()
6577            .into_iter()
6578            .collect::<Vec<_>>();
6579        let mut diagnostic_windows = event
6580            .windows
6581            .iter()
6582            .map(|entry| entry.table.clone())
6583            .collect::<BTreeSet<_>>()
6584            .into_iter()
6585            .collect::<Vec<_>>();
6586        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6587            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6588        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6589        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6590        self.last_change = Some(DiagnosticLastChange {
6591            revision: event.revision.clone(),
6592            recorded_at_ms: self.clock_now_ms(),
6593            tables: diagnostic_tables,
6594            windows: diagnostic_windows,
6595            domains_truncated,
6596            status_changed: event.status.is_some(),
6597            conflicts_changed: event.conflicts_changed,
6598            rejections_changed: event.rejections_changed,
6599            outcomes_changed: event.outcomes_changed,
6600        });
6601        self.change_queue.push_back(event);
6602        Ok(())
6603    }
6604
6605    fn record_scope_map(
6606        &self,
6607        batch: &mut ChangeAccumulator,
6608        table_name: &str,
6609        scopes: &[(String, Vec<String>)],
6610    ) {
6611        let Some(table) = self.schema.table(table_name) else {
6612            return;
6613        };
6614        for (variable, values) in scopes {
6615            let Some(scope) = table
6616                .scope_variables
6617                .iter()
6618                .find(|scope| &scope.variable == variable)
6619            else {
6620                continue;
6621            };
6622            for value in values {
6623                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6624            }
6625        }
6626    }
6627
6628    /// Record a row's current scope keys from the base or visible table.
6629    fn record_row_scopes(
6630        &self,
6631        batch: &mut ChangeAccumulator,
6632        table_name: &str,
6633        row_id: &str,
6634        base: bool,
6635    ) -> bool {
6636        let Some(table) = self.schema.table(table_name) else {
6637            return false;
6638        };
6639        if table.scope_variables.is_empty() {
6640            return false;
6641        }
6642        let columns = table
6643            .scope_variables
6644            .iter()
6645            .map(|scope| quote_ident(&scope.column))
6646            .collect::<Vec<_>>()
6647            .join(", ");
6648        let full_table = if base {
6649            base_table(table_name)
6650        } else {
6651            visible_table(table_name)
6652        };
6653        let sql = format!(
6654            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6655            row_id_predicate(table)
6656        );
6657        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6658            return false;
6659        };
6660        let values = stmt.query_row(rusqlite::params![row_id], |row| {
6661            let mut values = Vec::with_capacity(table.scope_variables.len());
6662            for index in 0..table.scope_variables.len() {
6663                values.push(row.get::<_, Option<String>>(index)?);
6664            }
6665            Ok(values)
6666        });
6667        let Ok(values) = values else {
6668            return false;
6669        };
6670        let mut recorded = false;
6671        for (scope, value) in table.scope_variables.iter().zip(values) {
6672            if let Some(value) = value {
6673                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6674                recorded = true;
6675            }
6676        }
6677        recorded
6678    }
6679
6680    fn record_commit_changes(
6681        &self,
6682        batch: &mut ChangeAccumulator,
6683        tables: &[String],
6684        changes: &[ssp2::model::Change],
6685    ) {
6686        #[cfg(feature = "bench-internals")]
6687        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6688        for change in changes {
6689            let Some(table_name) = tables.get(change.table_index as usize) else {
6690                continue;
6691            };
6692            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6693            if let Some(table) = self.schema.table(table_name) {
6694                for (variable, value) in &change.scopes {
6695                    if let Some(scope) = table
6696                        .scope_variables
6697                        .iter()
6698                        .find(|scope| &scope.variable == variable)
6699                    {
6700                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
6701                        precise = true;
6702                    }
6703                }
6704            }
6705            if !precise {
6706                batch.table(table_name);
6707            }
6708        }
6709    }
6710
6711    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6712        if effective.is_empty() {
6713            return false;
6714        }
6715        let Some(table) = self.schema.table(table_name) else {
6716            return false;
6717        };
6718        let mut clauses = Vec::new();
6719        let mut params = Vec::new();
6720        for (variable, values) in effective {
6721            let Some(column) = table.scope_column(variable) else {
6722                return false;
6723            };
6724            if values.is_empty() {
6725                return false;
6726            }
6727            let placeholders = values
6728                .iter()
6729                .map(|value| {
6730                    params.push(SqlValue::Text(value.clone()));
6731                    "?"
6732                })
6733                .collect::<Vec<_>>()
6734                .join(", ");
6735            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6736        }
6737        let sql = format!(
6738            "SELECT 1 FROM {} WHERE {} LIMIT 1",
6739            base_table(table_name),
6740            clauses.join(" AND ")
6741        );
6742        self.conn
6743            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6744            .is_ok()
6745    }
6746
6747    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
6748    pub fn upgrading(&self) -> bool {
6749        self.upgrading
6750    }
6751
6752    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
6753    /// keeping this client's local database (identity, outbox, tables). The
6754    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
6755    /// version changed. Mirrors the TS client's boot-time detection —
6756    /// the Rust core has no persistent restart, so recreation IS the boot.
6757    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6758        let new_schema = parse_schema_json(schema_json)?;
6759        let marker: Option<i32> = self
6760            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6761            .and_then(|v| v.parse().ok());
6762        self.schema = new_schema;
6763        self.create_bookkeeping_tables()?;
6764        if marker != Some(self.schema.version) {
6765            self.run_schema_reset()?;
6766        }
6767        self.prune_unknown_subscriptions()?;
6768        // The conformance recreate is the in-memory equivalent of reopening a
6769        // durable client. Apply the same startup catch-up contract even when
6770        // the schema itself did not change.
6771        self.enqueue_startup_sync_if_needed()?;
6772        Ok(())
6773    }
6774
6775    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6776        let startup_work = !self.stopped
6777            && (!self.outbox.is_empty()
6778                || !self.load_pending_evictions()?.is_empty()
6779                || self.subs.iter().any(|sub| sub.state == SubState::Active));
6780        if startup_work {
6781            self.sync_needed = true;
6782            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6783        }
6784        Ok(())
6785    }
6786
6787    /// A native client persists schema-floor stops across process restarts.
6788    /// Once the running app already satisfies that floor, the persisted stop
6789    /// is only stale evidence from an older server round (for example, the
6790    /// server was restarted after catching up to an app that was ahead).
6791    /// Clear it and let the normal startup pull re-negotiate. If the server is
6792    /// still incompatible it will return the floor again in that first round.
6793    fn clear_satisfied_persisted_schema_floor(&mut self) {
6794        let satisfied = self
6795            .schema_floor
6796            .as_ref()
6797            .and_then(|floor| floor.required_schema_version)
6798            .is_some_and(|required| self.schema.version >= required);
6799        if !satisfied {
6800            return;
6801        }
6802        self.schema_floor = None;
6803        self.stopped = false;
6804        self.delete_meta(SCHEMA_FLOOR_KEY);
6805    }
6806
6807    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
6808    /// path already swept it; this covers a same-version or fresh-install open,
6809    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
6810    /// an aware binary can apply.
6811    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6812        let Some(replica_path) = self.previous_version_replica_path() else {
6813            return Ok(());
6814        };
6815        let max_age_ms = self
6816            .previous_version
6817            .filter(|config| config.enabled)
6818            .map(|config| config.max_age_ms);
6819        reconcile_previous_version_at_boot(
6820            &self.conn,
6821            &replica_path,
6822            self.schema.version,
6823            max_age_ms,
6824            self.clock_now_ms(),
6825        )
6826    }
6827
6828    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
6829    /// no file — an in-memory replica returns an empty path, and a `file:` URI
6830    /// has no plain sibling path — so the feature resolves as off there.
6831    fn previous_version_replica_path(&self) -> Option<String> {
6832        let path = self.conn.path()?;
6833        if path.is_empty() || path.starts_with("file:") {
6834            return None;
6835        }
6836        Some(path.to_owned())
6837    }
6838
6839    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
6840    /// and leaseState. Drops/recreates every synced table from the new
6841    /// schema, resets subscription sync-state (keeping registrations), clears
6842    /// the schema-floor stop state, rewrites the marker, drops outbox commits
6843    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
6844    fn run_schema_reset(&mut self) -> Result<(), String> {
6845        self.begin_observation("syncular_schema_reset")?;
6846        let mut batch = ChangeAccumulator::default();
6847        let result = self.run_schema_reset_observed(&mut batch, true, true);
6848        if let Err(error) = result {
6849            self.rollback_observation("syncular_schema_reset");
6850            return Err(error);
6851        }
6852        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6853            self.rollback_observation("syncular_schema_reset");
6854            return Err(error);
6855        }
6856        Ok(())
6857    }
6858
6859    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6860        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6861        self.begin_observation("syncular_log_epoch_reset")?;
6862        let mut batch = ChangeAccumulator::default();
6863        let result = self.run_schema_reset_observed(&mut batch, false, false);
6864        if let Err(error) = result {
6865            self.rollback_observation("syncular_log_epoch_reset");
6866            return Err(error);
6867        }
6868        self.set_meta(LOG_EPOCH_KEY, log_epoch);
6869        self.sync_needed = true;
6870        batch.status = true;
6871        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6872            self.rollback_observation("syncular_log_epoch_reset");
6873            return Err(error);
6874        }
6875        self.sync_intent_queue.push_back(SyncIntent::Interactive);
6876        Ok(resets)
6877    }
6878
6879    fn run_schema_reset_observed(
6880        &mut self,
6881        batch: &mut ChangeAccumulator,
6882        drop_incompatible: bool,
6883        capture: bool,
6884    ) -> Result<(), String> {
6885        self.upgrading = true;
6886        batch.status = true;
6887        // RFC 0005 D6: classify the pending outbox against the NEW compiled
6888        // schema BEFORE the wipe, and record the advisory audit. It drops
6889        // nothing — the §7.4.4 send-time drop stays the only path that removes
6890        // a commit. The classification is shared with that drop so the two can
6891        // never disagree about what is incompatible.
6892        if self.previous_version.is_some_and(|config| config.enabled) {
6893            let previous_version = self
6894                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6895                .and_then(|value| value.parse::<i32>().ok())
6896                .unwrap_or(self.schema.version);
6897            let pending = self
6898                .outbox
6899                .iter()
6900                .map(|commit| PendingCommitAudit {
6901                    commit_id: &commit.client_commit_id,
6902                    operations: commit_audit_operations(commit),
6903                })
6904                .collect::<Vec<_>>();
6905            crate::previous_version::write_previous_version_audit(
6906                &self.conn,
6907                &crate::previous_version::build_previous_version_audit(
6908                    &self.schema,
6909                    &pending,
6910                    previous_version,
6911                    self.schema.version,
6912                    self.clock_now_ms(),
6913                ),
6914            );
6915        }
6916        // RFC 0005 D5: the container lives in its own FILE, so the reset can
6917        // never reach it. Sweep first (unconditional — also on the log-epoch
6918        // reset, which captures nothing), then capture BEFORE the wipe drops
6919        // any row. A refused capture records its reason and leaves no file.
6920        if let Some(replica_path) = self.previous_version_replica_path() {
6921            sweep_previous_version_container(&self.conn, &replica_path)?;
6922            if capture {
6923                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
6924                    let previous_version = self
6925                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6926                        .and_then(|value| value.parse::<i32>().ok())
6927                        .unwrap_or(self.schema.version);
6928                    capture_previous_version_from_replica(
6929                        &self.conn,
6930                        &replica_path,
6931                        previous_version,
6932                        self.schema.version,
6933                        &config,
6934                        self.clock_now_ms(),
6935                    )?;
6936                }
6937            }
6938        }
6939        for table in &self.schema.tables {
6940            batch.table(&table.name);
6941        }
6942        for (base_key, unit, table) in self.load_registered_window_units() {
6943            batch.window(&base_key, &table, &unit);
6944        }
6945        // The per-table insert SQL is derived from the OLD column lists.
6946        self.insert_sql.borrow_mut().clear();
6947        self.overlay_dirty.set(true);
6948        // Drop every synced table (base + visible) that currently exists —
6949        // discovered from sqlite_master so a bump that adds/removes tables is
6950        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
6951        // and the blob cache) are preserved; base tables are `_syncular_base_*`
6952        // so they are matched explicitly, not by the bookkeeping filter.
6953        // Drop virtual tables first so SQLite removes every FTS shadow table
6954        // atomically instead of the generic discovery tearing it apart.
6955        let virtual_tables: Vec<String> = {
6956            let mut stmt = self
6957                .conn
6958                .prepare(
6959                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
6960                )
6961                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6962            let rows = stmt
6963                .query_map([], |row| row.get::<_, String>(0))
6964                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6965            rows.filter_map(Result::ok)
6966                .filter(|name| is_synced_table_name(name))
6967                .collect()
6968        };
6969        for name in virtual_tables {
6970            self.conn
6971                .execute_batch(&format!(
6972                    "DROP TABLE IF EXISTS {}",
6973                    quote_ident(&format!("_syncular_fts_{name}"))
6974                ))
6975                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6976            self.conn
6977                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6978                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6979        }
6980        let existing: Vec<String> = {
6981            let mut stmt = self
6982                .conn
6983                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
6984                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6985            let rows = stmt
6986                .query_map([], |row| row.get::<_, String>(0))
6987                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6988            rows.filter_map(Result::ok)
6989                .filter(|name| is_synced_table_name(name))
6990                .collect()
6991        };
6992        for name in &existing {
6993            if let Some(table) = name.strip_prefix("_syncular_base_") {
6994                batch.table(table);
6995            } else if !name.starts_with("_syncular_") {
6996                batch.table(name);
6997            }
6998        }
6999        for name in existing {
7000            self.conn
7001                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7002                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7003        }
7004        // Recreate the synced tables from the NEW schema.
7005        self.create_synced_tables()?;
7006        // Reset every subscription's sync-state, keeping the registration.
7007        for sub in &mut self.subs {
7008            sub.cursor = -1;
7009            sub.bootstrap_state = None;
7010            sub.effective = None;
7011            sub.state = SubState::Active;
7012            sub.reason_code = None;
7013            sub.synced_once = false;
7014        }
7015        let subs = self.subs.clone();
7016        for sub in &subs {
7017            self.persist_sub(sub)?;
7018        }
7019        // The stop state is over: this client now ships a servable schema.
7020        self.stopped = false;
7021        self.schema_floor = None;
7022        self.delete_meta(SCHEMA_FLOOR_KEY);
7023        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
7024        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
7025        // D1: the descriptor follows every marker write. A crash in between
7026        // self-heals on the next same-version open, which backfills it.
7027        set_local_schema_descriptor(&self.conn, &self.schema);
7028        // §7.4.4: drop outbox commits that cannot re-encode under the new
7029        // schema (a referenced column/table the bump removed), surfacing each
7030        // as a `sync.outbox_incompatible` rejection.
7031        if drop_incompatible && self.drop_incompatible_outbox()? {
7032            batch.rejections = true;
7033            batch.status = true;
7034            batch.outcomes = true;
7035        }
7036        // Re-apply the surviving outbox optimistically over the empty tables.
7037        self.rebuild_overlay();
7038        Ok(())
7039    }
7040
7041    /// §7.4.4: a persisted upsert whose values reference a column the current
7042    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
7043    /// and raise a client-local `sync.outbox_incompatible` rejection.
7044    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
7045        let incompatible = self
7046            .outbox
7047            .iter()
7048            .filter(|commit| {
7049                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
7050            })
7051            .cloned()
7052            .collect::<Vec<_>>();
7053        if incompatible.is_empty() {
7054            return Ok(false);
7055        }
7056        let mut rejections = Vec::new();
7057        for commit in &incompatible {
7058            let results = commit
7059                .ops
7060                .iter()
7061                .enumerate()
7062                .map(|(op_index, operation)| {
7063                    let rejection = RejectionRecord {
7064                        client_commit_id: commit.client_commit_id.clone(),
7065                        op_index: op_index as i32,
7066                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
7067                        message: "the persisted commit cannot encode under the current schema"
7068                            .to_owned(),
7069                        retryable: false,
7070                        details: None,
7071                        operation: Some(CommitOperation::from(operation)),
7072                    };
7073                    rejections.push(rejection.clone());
7074                    CommitOperationOutcome::Error { rejection }
7075                })
7076                .collect::<Vec<_>>();
7077            self.persist_commit_outcome(
7078                &commit.client_commit_id,
7079                CommitOutcomeStatus::Rejected,
7080                &results,
7081                Some(&commit.ops),
7082            )?;
7083            self.delete_outbox_persisted(&commit.client_commit_id)?;
7084        }
7085        self.prune_commit_outcomes()?;
7086        let incompatible_ids = incompatible
7087            .iter()
7088            .map(|commit| commit.client_commit_id.as_str())
7089            .collect::<BTreeSet<_>>();
7090        self.outbox
7091            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7092        self.rejections.extend(rejections);
7093        Ok(true)
7094    }
7095
7096    /// §7.4.3: (re)create the base + visible table pair for every synced
7097    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
7098    fn create_synced_tables(&self) -> Result<(), String> {
7099        for table in &self.schema.tables {
7100            // The base half + the visible half form the synced-table pair. An
7101            // index name is global in SQLite, so the base half's indexes are
7102            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
7103            for (full, index_prefix) in [
7104                (base_table(&table.name), "_syncular_base_"),
7105                (visible_table(&table.name), ""),
7106            ] {
7107                let mut cols: Vec<String> =
7108                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7109                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7110                let sql = format!(
7111                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7112                    cols.join(", "),
7113                    quote_ident(&table.primary_key)
7114                );
7115                self.conn
7116                    .execute(&sql, [])
7117                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7118                // Local secondary indexes (CREATE INDEX subset). Created on
7119                // both halves so mirror reads hit an index on either. Runs on
7120                // both the initial create and the §7.4.3 reset recreate path.
7121                for index in &table.indexes {
7122                    let unique = if index.unique { "UNIQUE " } else { "" };
7123                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7124                    let cols_sql = index
7125                        .columns
7126                        .iter()
7127                        .map(|c| quote_ident(c))
7128                        .collect::<Vec<_>>()
7129                        .join(", ");
7130                    let index_sql = format!(
7131                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7132                    );
7133                    self.conn
7134                        .execute(&index_sql, [])
7135                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7136                }
7137            }
7138        }
7139        // FTS exists only on the visible half. It is a local search
7140        // projection, never a synced/base table or a wire-schema member.
7141        for table in &self.schema.tables {
7142            for index in &table.fts_indexes {
7143                self.create_fts_projection(table, index)?;
7144            }
7145        }
7146        Ok(())
7147    }
7148
7149    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7150        let count: i64 = self
7151            .conn
7152            .query_row(
7153                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7154                rusqlite::params![index.name],
7155                |row| row.get(0),
7156            )
7157            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7158        Ok(count > 0)
7159    }
7160
7161    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7162        for suffix in ["ai", "ad", "au"] {
7163            self.conn
7164                .execute(
7165                    &format!(
7166                        "DROP TRIGGER IF EXISTS {}",
7167                        quote_ident(&format!("{}_{suffix}", index.name))
7168                    ),
7169                    [],
7170                )
7171                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7172        }
7173        Ok(())
7174    }
7175
7176    fn create_fts_triggers(
7177        &self,
7178        table: &TableSchema,
7179        index: &FtsIndexSchema,
7180    ) -> Result<(), String> {
7181        let fts = quote_ident(&index.name);
7182        let source = visible_table(&table.name);
7183        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7184        let pk = quote_ident(&table.primary_key);
7185        let projection_columns = std::iter::once(source_id.clone())
7186            .chain(index.columns.iter().map(|column| quote_ident(column)))
7187            .collect::<Vec<_>>()
7188            .join(", ");
7189        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7190            .chain(
7191                index
7192                    .columns
7193                    .iter()
7194                    .map(|column| format!("new.{}", quote_ident(column))),
7195            )
7196            .collect::<Vec<_>>()
7197            .join(", ");
7198        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7199        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)");
7200        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)");
7201        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})");
7202        let bi = quote_ident(&format!("{}_bi", index.name));
7203        let ai = quote_ident(&format!("{}_ai", index.name));
7204        let ad = quote_ident(&format!("{}_ad", index.name));
7205        let au = quote_ident(&format!("{}_au", index.name));
7206        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7207        let sql = format!(
7208            "DROP TRIGGER IF EXISTS {bi};
7209             DROP TRIGGER IF EXISTS {ai};
7210             DROP TRIGGER IF EXISTS {ad};
7211             DROP TRIGGER IF EXISTS {au};
7212             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7213             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7214             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7215             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7216        );
7217        self.conn
7218            .execute_batch(&sql)
7219            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7220    }
7221
7222    fn rebuild_fts_projection(
7223        &self,
7224        table: &TableSchema,
7225        index: &FtsIndexSchema,
7226    ) -> Result<(), String> {
7227        let fts = quote_ident(&index.name);
7228        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7229        let pk = quote_ident(&table.primary_key);
7230        let indexed_columns = index
7231            .columns
7232            .iter()
7233            .map(|column| quote_ident(column))
7234            .collect::<Vec<_>>();
7235        let projection_columns = std::iter::once(source_id.clone())
7236            .chain(indexed_columns.iter().cloned())
7237            .collect::<Vec<_>>()
7238            .join(", ");
7239        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7240        let sql = format!(
7241            "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};",
7242            columns = indexed_columns.join(", "),
7243            source = visible_table(&table.name),
7244        );
7245        self.conn
7246            .execute_batch(&sql)
7247            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7248    }
7249
7250    fn create_fts_projection(
7251        &self,
7252        table: &TableSchema,
7253        index: &FtsIndexSchema,
7254    ) -> Result<(), String> {
7255        self.begin_observation("syncular_fts_schema")?;
7256        let result = (|| {
7257            let existed = self.fts_projection_exists(index)?;
7258            let tokenizer = index.tokenize.replace('\'', "''");
7259            let columns = index
7260                .columns
7261                .iter()
7262                .map(|column| quote_ident(column))
7263                .collect::<Vec<_>>()
7264                .join(", ");
7265            let sql = format!(
7266            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7267            fts = quote_ident(&index.name),
7268            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7269        );
7270            self.conn.execute(&sql, []).map_err(|error| {
7271                format!(
7272                    "cannot create local FTS5 projection {:?}: {error}",
7273                    index.name
7274                )
7275            })?;
7276            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7277            let mapped: bool = self
7278                .conn
7279                .query_row(
7280                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7281                    [format!("_syncular_fts_{}", index.name)],
7282                    |row| row.get(0),
7283                )
7284                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7285            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))?;
7286            if !mapped && existed {
7287                self.conn
7288                    .execute_batch(&format!(
7289                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7290                        quote_ident(FTS_SOURCE_ID_COLUMN),
7291                        quote_ident(&index.name)
7292                    ))
7293                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7294            }
7295            self.create_fts_triggers(table, index)?;
7296            if !existed {
7297                self.rebuild_fts_projection(table, index)?;
7298            }
7299            Ok(())
7300        })();
7301        if let Err(error) = result {
7302            self.rollback_observation("syncular_fts_schema");
7303            return Err(error);
7304        }
7305        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7306            self.rollback_observation("syncular_fts_schema");
7307            return Err(error.to_string());
7308        }
7309        Ok(())
7310    }
7311
7312    // -- persistence write-through --------------------------------------------
7313
7314    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7315        let state = serde_json::json!({
7316            "requested": scope_map_to_json(&sub.requested),
7317            "params": sub.params,
7318            "cursor": sub.cursor,
7319            "bootstrapState": sub.bootstrap_state,
7320            "status": sub.state.name(),
7321            "reasonCode": sub.reason_code,
7322            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7323            "syncedOnce": sub.synced_once,
7324        });
7325        self.conn.execute(
7326            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7327            rusqlite::params![sub.id, sub.table, state.to_string()],
7328        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7329    }
7330
7331    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7332        let ops: Vec<Value> = commit
7333            .ops
7334            .iter()
7335            .map(|op| {
7336                serde_json::json!({
7337                    "op": if op.upsert { "upsert" } else { "delete" },
7338                    "table": op.table,
7339                    "rowId": op.row_id,
7340                    "baseVersion": op.base_version,
7341                    "values": op.values.clone().map(Value::Object),
7342                })
7343            })
7344            .collect();
7345        self.conn
7346            .execute(
7347                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7348                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7349            )
7350            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7351        for operation in &commit.ops {
7352            let Some(values) = &operation.values else {
7353                continue;
7354            };
7355            let Some(table) = self.schema.table(&operation.table) else {
7356                continue;
7357            };
7358            for column in table
7359                .columns
7360                .iter()
7361                .filter(|column| column.ty == ColumnType::BlobRef)
7362            {
7363                let Some(Value::String(raw)) = values.get(&column.name) else {
7364                    continue;
7365                };
7366                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7367                    continue;
7368                };
7369                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7370                    continue;
7371                };
7372                self.conn
7373                    .execute(
7374                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7375                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7376                        rusqlite::params![commit.client_commit_id, blob_id],
7377                    )
7378                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7379            }
7380        }
7381        Ok(())
7382    }
7383
7384    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7385        self.conn
7386            .execute(
7387                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7388                rusqlite::params![client_commit_id],
7389            )
7390            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7391        self.conn
7392            .execute(
7393                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7394                rusqlite::params![client_commit_id],
7395            )
7396            .map(|_| ())
7397            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7398    }
7399
7400    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7401        match status {
7402            CommitOutcomeStatus::Applied => "applied",
7403            CommitOutcomeStatus::Cached => "cached",
7404            CommitOutcomeStatus::Conflict => "conflict",
7405            CommitOutcomeStatus::Rejected => "rejected",
7406        }
7407    }
7408
7409    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7410        match resolution {
7411            CommitOutcomeResolution::Active => "active",
7412            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7413            CommitOutcomeResolution::Superseded => "superseded",
7414            CommitOutcomeResolution::Dismissed => "dismissed",
7415        }
7416    }
7417
7418    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7419        match value {
7420            "applied" => Ok(CommitOutcomeStatus::Applied),
7421            "cached" => Ok(CommitOutcomeStatus::Cached),
7422            "conflict" => Ok(CommitOutcomeStatus::Conflict),
7423            "rejected" => Ok(CommitOutcomeStatus::Rejected),
7424            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7425        }
7426    }
7427
7428    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7429        match value {
7430            "active" => Ok(CommitOutcomeResolution::Active),
7431            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7432            "superseded" => Ok(CommitOutcomeResolution::Superseded),
7433            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7434            _ => Err(format!(
7435                "invalid persisted commit outcome resolution {value:?}"
7436            )),
7437        }
7438    }
7439
7440    fn persist_commit_outcome(
7441        &self,
7442        client_commit_id: &str,
7443        status: CommitOutcomeStatus,
7444        results: &[CommitOperationOutcome],
7445        operations: Option<&[OutboxOp]>,
7446    ) -> Result<(), String> {
7447        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7448        let operations_json = operations
7449            .map(|items| {
7450                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7451            })
7452            .transpose()
7453            .map_err(|error| error.to_string())?;
7454        self.conn
7455            .execute(
7456                "INSERT INTO _syncular_commit_outcomes (
7457                   client_commit_id, status, recorded_at_ms, results_json,
7458                   operations_json, resolution
7459                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7460                rusqlite::params![
7461                    client_commit_id,
7462                    Self::outcome_status_name(status),
7463                    self.clock_now_ms(),
7464                    results_json,
7465                    operations_json
7466                ],
7467            )
7468            .map(|_| ())
7469            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7470    }
7471
7472    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7473        let StoredCommitOutcomeRow {
7474            sequence,
7475            client_commit_id,
7476            status,
7477            recorded_at_ms,
7478            results_json,
7479            operations_json,
7480            resolution,
7481            resolved_at_ms,
7482            replacement_client_commit_id,
7483        } = row;
7484        Ok(CommitOutcome {
7485            sequence,
7486            client_commit_id,
7487            status: Self::parse_outcome_status(&status)?,
7488            recorded_at_ms,
7489            results: serde_json::from_str(&results_json)
7490                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7491            operations: operations_json
7492                .map(|value| {
7493                    serde_json::from_str(&value).map_err(|error| {
7494                        format!("invalid persisted commit outcome operations: {error}")
7495                    })
7496                })
7497                .transpose()?,
7498            resolution: Self::parse_outcome_resolution(&resolution)?,
7499            resolved_at_ms,
7500            replacement_client_commit_id,
7501        })
7502    }
7503
7504    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7505        let row = self
7506            .conn
7507            .query_row(
7508                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7509                        resolution, resolved_at_ms, replacement_client_commit_id
7510                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7511                rusqlite::params![client_commit_id],
7512                |row| {
7513                    Ok(StoredCommitOutcomeRow {
7514                        sequence: row.get(0)?,
7515                        client_commit_id: row.get(1)?,
7516                        status: row.get(2)?,
7517                        recorded_at_ms: row.get(3)?,
7518                        results_json: row.get(4)?,
7519                        operations_json: row.get(5)?,
7520                        resolution: row.get(6)?,
7521                        resolved_at_ms: row.get(7)?,
7522                        replacement_client_commit_id: row.get(8)?,
7523                    })
7524                },
7525            )
7526            .optional()
7527            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7528        row.map(Self::outcome_from_row).transpose()
7529    }
7530
7531    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7532        if query.limit == Some(0) {
7533            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7534        }
7535        let mut sql = String::from(
7536            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7537                    resolution, resolved_at_ms, replacement_client_commit_id
7538               FROM _syncular_commit_outcomes",
7539        );
7540        if query.active_only {
7541            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7542        }
7543        sql.push_str(" ORDER BY seq DESC");
7544        if let Some(limit) = query.limit {
7545            sql.push_str(&format!(" LIMIT {limit}"));
7546        }
7547        let mut stmt = self
7548            .conn
7549            .prepare(&sql)
7550            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7551        let rows = stmt
7552            .query_map([], |row| {
7553                Ok(StoredCommitOutcomeRow {
7554                    sequence: row.get(0)?,
7555                    client_commit_id: row.get(1)?,
7556                    status: row.get(2)?,
7557                    recorded_at_ms: row.get(3)?,
7558                    results_json: row.get(4)?,
7559                    operations_json: row.get(5)?,
7560                    resolution: row.get(6)?,
7561                    resolved_at_ms: row.get(7)?,
7562                    replacement_client_commit_id: row.get(8)?,
7563                })
7564            })
7565            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7566        let mut outcomes = Vec::new();
7567        for row in rows {
7568            outcomes.push(Self::outcome_from_row(row.map_err(|error| {
7569                Self::sqlite_failure(&self.storage_failure, error)
7570            })?)?);
7571        }
7572        Ok(outcomes)
7573    }
7574
7575    fn prune_commit_outcomes(&self) -> Result<(), String> {
7576        #[cfg(test)]
7577        self.outcome_prune_count
7578            .set(self.outcome_prune_count.get() + 1);
7579        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7580        let count = self
7581            .conn
7582            .query_row(
7583                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7584                [],
7585                |row| row.get::<_, i64>(0),
7586            )
7587            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
7588            as usize;
7589        let excess = count.saturating_sub(max_entries);
7590        if excess == 0 {
7591            return Ok(());
7592        }
7593        let mut stmt = self
7594            .conn
7595            .prepare(
7596                "SELECT seq FROM _syncular_commit_outcomes
7597                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
7598                  ORDER BY seq ASC LIMIT ?1",
7599            )
7600            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7601        let rows = stmt
7602            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7603            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7604        let sequences = rows
7605            .collect::<Result<Vec<_>, _>>()
7606            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7607        drop(stmt);
7608        for sequence in sequences {
7609            self.conn
7610                .execute(
7611                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7612                    rusqlite::params![sequence],
7613                )
7614                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7615        }
7616        Ok(())
7617    }
7618
7619    // -- driver surface ---------------------------------------------------------
7620
7621    pub fn subscribe(
7622        &mut self,
7623        id: String,
7624        table: String,
7625        scopes: Vec<(String, Vec<String>)>,
7626        params: Option<String>,
7627    ) -> Result<(), String> {
7628        if self.schema.table(&table).is_none() {
7629            return Err(format!("unknown table {table:?}"));
7630        }
7631        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7632            let same_intent = existing.table == table
7633                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7634                && existing.params == params;
7635            if same_intent {
7636                return Ok(());
7637            }
7638            return Err(
7639                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7640                    .to_owned(),
7641            );
7642        }
7643        let sub = Subscription {
7644            id: id.clone(),
7645            table,
7646            requested: scopes,
7647            params,
7648            cursor: -1,
7649            bootstrap_state: None,
7650            state: SubState::Active,
7651            reason_code: None,
7652            effective: None,
7653            synced_once: false,
7654        };
7655        self.persist_sub(&sub)?;
7656        self.subs.push(sub);
7657        Ok(())
7658    }
7659
7660    pub fn unsubscribe(&mut self, id: &str) {
7661        self.subs.retain(|s| s.id != id);
7662        let _ = self.conn.execute(
7663            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7664            rusqlite::params![id],
7665        );
7666    }
7667
7668    // -- windowed subscriptions (§4.8) ------------------------------------------
7669
7670    /// §4.8: set the live window units for a base — a value-sharded family
7671    /// of subscriptions, one per unit. Added units get fresh subscriptions
7672    /// (image-lane bootstrap on the next sync); removed units are
7673    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
7674    /// Idempotent; re-entry cancels any deferred eviction.
7675    pub fn set_window(
7676        &mut self,
7677        base: &WindowBase,
7678        units: &[String],
7679    ) -> Result<CommandEffects, String> {
7680        let table = self
7681            .schema
7682            .table(&base.table)
7683            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7684        if table.scope_column(&base.variable).is_none() {
7685            return Err(format!(
7686                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7687                base.table, base.variable
7688            ));
7689        }
7690        let base_key = window_base_key(base);
7691        let wanted: std::collections::HashSet<&String> = units.iter().collect();
7692        let live = self.load_window_units(&base_key);
7693        self.begin_observation("syncular_window")?;
7694        let mut batch = ChangeAccumulator::default();
7695        let mut changed = false;
7696
7697        let prior_subs = self.subs.clone();
7698        let widened = (|| {
7699            // Widen: units wanted but not live → fresh subscription + registry row.
7700            for unit in units {
7701                if live.iter().any(|(u, _)| u == unit) {
7702                    continue;
7703                }
7704                let sub_id = derive_sub_id(base, unit);
7705                self.delete_pending_evict(&sub_id)?;
7706                self.insert_window_unit(&base_key, unit, &sub_id)?;
7707                self.subscribe(
7708                    sub_id,
7709                    base.table.clone(),
7710                    unit_scopes(base, unit),
7711                    base.params.clone(),
7712                )?;
7713                batch.window(&base_key, &base.table, unit);
7714                changed = true;
7715            }
7716
7717            self.finish_observation("syncular_window", batch)
7718        })();
7719        if let Err(error) = widened {
7720            self.rollback_observation("syncular_window");
7721            self.subs = prior_subs;
7722            return Err(error);
7723        }
7724        for (unit, sub_id) in live {
7725            if wanted.contains(&unit) {
7726                continue;
7727            }
7728            self.rebuild_overlay_if_dirty();
7729            let prior_subs = self.subs.clone();
7730            self.begin_observation("syncular_window")?;
7731            let effective = self
7732                .subs
7733                .iter()
7734                .find(|sub| sub.id == sub_id)
7735                .and_then(|sub| sub.effective.clone())
7736                .unwrap_or_else(|| unit_scopes(base, &unit));
7737            let mut batch = ChangeAccumulator::default();
7738            self.record_scope_map(&mut batch, &base.table, &effective);
7739            batch.window(&base_key, &base.table, &unit);
7740            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7741            let remaining = match result {
7742                Ok(remaining) => remaining,
7743                Err(error) => {
7744                    self.rollback_observation("syncular_window");
7745                    self.subs = prior_subs;
7746                    return Err(error);
7747                }
7748            };
7749            if let Err(error) = self.finish_observation("syncular_window", batch) {
7750                self.rollback_observation("syncular_window");
7751                self.subs = prior_subs;
7752                return Err(error);
7753            }
7754            if remaining {
7755                self.drain_pending_evictions()?;
7756            }
7757            changed = true;
7758        }
7759        Ok(if changed {
7760            CommandEffects::interactive()
7761        } else {
7762            CommandEffects::none()
7763        })
7764    }
7765
7766    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
7767    /// the subset still bootstrap-pending. Registration alone is not
7768    /// completeness — a unit is pending until its subscription completes a
7769    /// bootstrap round (cursor advances past -1 with no resume token held).
7770    pub fn window_state(&self, base: &WindowBase) -> WindowState {
7771        let mut units = Vec::new();
7772        let mut pending = Vec::new();
7773        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7774            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7775                Some(sub) => {
7776                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7777                }
7778                None => true,
7779            };
7780            if is_pending {
7781                pending.push(unit.clone());
7782            }
7783            units.push(unit);
7784        }
7785        WindowState { units, pending }
7786    }
7787
7788    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7789        let mut stmt = match self
7790            .conn
7791            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7792        {
7793            Ok(stmt) => stmt,
7794            Err(_) => return Vec::new(),
7795        };
7796        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7797            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7798        });
7799        match rows {
7800            Ok(rows) => rows.filter_map(Result::ok).collect(),
7801            Err(_) => Vec::new(),
7802        }
7803    }
7804
7805    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7806        let mut stmt = match self.conn.prepare(
7807            "SELECT windows.base, windows.unit, subscriptions.tbl
7808               FROM _syncular_windows AS windows
7809               JOIN _syncular_subscriptions AS subscriptions
7810                 ON subscriptions.id = windows.sub_id
7811               ORDER BY windows.base, windows.unit",
7812        ) {
7813            Ok(stmt) => stmt,
7814            Err(_) => return Vec::new(),
7815        };
7816        let rows = stmt.query_map([], |row| {
7817            Ok((
7818                row.get::<_, String>(0)?,
7819                row.get::<_, String>(1)?,
7820                row.get::<_, String>(2)?,
7821            ))
7822        });
7823        match rows {
7824            Ok(rows) => rows.filter_map(Result::ok).collect(),
7825            Err(_) => Vec::new(),
7826        }
7827    }
7828
7829    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
7830        self.conn
7831            .query_row(
7832                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
7833                rusqlite::params![sub_id],
7834                |row| Ok((row.get(0)?, row.get(1)?)),
7835            )
7836            .ok()
7837    }
7838
7839    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
7840        self.conn
7841            .execute(
7842                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
7843                rusqlite::params![base_key, unit, sub_id],
7844            )
7845            .map(|_| ())
7846            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7847    }
7848
7849    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
7850        self.conn
7851            .execute(
7852                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
7853                rusqlite::params![base_key, unit],
7854            )
7855            .map(|_| ())
7856            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7857    }
7858
7859    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
7860    /// Deletes the unit's rows except those pinned by a pending outbox
7861    /// commit (E1); records a deferred eviction if any pin remains; discards
7862    /// the subscription's cursor/resume/effective-echo (E3) and version
7863    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
7864    /// nothing.
7865    fn evict_unit(
7866        &mut self,
7867        base_key: &str,
7868        base: &WindowBase,
7869        unit: &str,
7870        sub_id: &str,
7871    ) -> Result<bool, String> {
7872        let effective = self
7873            .subs
7874            .iter()
7875            .find(|s| s.id == sub_id)
7876            .and_then(|s| s.effective.clone())
7877            .unwrap_or_else(|| unit_scopes(base, unit));
7878        let pinned = self.pinned_row_ids(&base.table);
7879        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
7880        self.delete_window_unit(base_key, unit)?;
7881        self.conn
7882            .execute(
7883                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7884                [sub_id],
7885            )
7886            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7887        self.subs.retain(|sub| sub.id != sub_id);
7888        if deferred {
7889            self.save_pending_evict(sub_id, &base.table, &effective)?;
7890        } else {
7891            self.delete_pending_evict(sub_id)?;
7892        }
7893        Ok(remaining)
7894    }
7895
7896    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
7897    /// primary keys. Returns (more unpinned work, any scoped rows remain).
7898    /// A missing scope-column mapping fails without deleting rows.
7899    fn evict_scope_rows(
7900        &self,
7901        table_name: &str,
7902        effective: &[(String, Vec<String>)],
7903        pinned: &std::collections::HashSet<String>,
7904    ) -> Result<(bool, bool), String> {
7905        if effective.is_empty() {
7906            return Ok((false, false));
7907        }
7908        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
7909        let mut clauses = Vec::new();
7910        let mut params: Vec<SqlValue> = Vec::new();
7911        for (variable, values) in effective {
7912            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
7913            if values.is_empty() {
7914                return Ok((false, false));
7915            }
7916            let holes = values
7917                .iter()
7918                .map(|v| {
7919                    params.push(SqlValue::Text(v.clone()));
7920                    "?"
7921                })
7922                .collect::<Vec<_>>()
7923                .join(", ");
7924            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
7925        }
7926        let scope = clauses.join(" AND ");
7927        let scope_params = params.len();
7928        let pk = quote_ident(&table.primary_key);
7929        if !pinned.is_empty() {
7930            let holes = pinned
7931                .iter()
7932                .map(|id| {
7933                    params.push(SqlValue::Text(id.clone()));
7934                    "?"
7935                })
7936                .collect::<Vec<_>>()
7937                .join(", ");
7938            clauses.push(format!("{pk} NOT IN ({holes})"));
7939        }
7940        let predicate = clauses.join(" AND ");
7941        let base = base_table(table_name);
7942        let visible = visible_table(table_name);
7943        let mut ids: Vec<SqlValue> = Vec::new();
7944        for source in [&base, &visible] {
7945            let mut stmt = self
7946                .conn
7947                .prepare_cached(&format!(
7948                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
7949                ))
7950                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7951            ids = stmt
7952                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
7953                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
7954                .collect::<Result<_, _>>()
7955                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7956            if !ids.is_empty() {
7957                break;
7958            }
7959        }
7960        if !ids.is_empty() {
7961            let holes = vec!["?"; ids.len()].join(", ");
7962            for source in [&base, &visible] {
7963                self.conn
7964                    .execute(
7965                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
7966                        rusqlite::params_from_iter(&ids),
7967                    )
7968                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7969            }
7970        }
7971        let mut remaining = false;
7972        let mut deferred = false;
7973        for source in [&base, &visible] {
7974            remaining |= self
7975                .conn
7976                .query_row(
7977                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
7978                    rusqlite::params_from_iter(&params),
7979                    |row| row.get::<_, bool>(0),
7980                )
7981                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7982            deferred |= self
7983                .conn
7984                .query_row(
7985                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
7986                    rusqlite::params_from_iter(&params[..scope_params]),
7987                    |row| row.get::<_, bool>(0),
7988                )
7989                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7990        }
7991        Ok((remaining, deferred))
7992    }
7993
7994    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
7995    fn drain_pending_evictions(&mut self) -> Result<(), String> {
7996        let pending = self.load_pending_evictions()?;
7997        if pending.is_empty() {
7998            return Ok(());
7999        }
8000        self.rebuild_overlay_if_dirty();
8001        for (sub_id, table_name, effective) in pending {
8002            if self.schema.table(&table_name).is_none() {
8003                self.delete_pending_evict(&sub_id)?;
8004                continue;
8005            }
8006            loop {
8007                std::thread::yield_now();
8008                self.begin_observation("syncular_evict")?;
8009                let pinned = self.pinned_row_ids(&table_name);
8010                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
8011                let (remaining, deferred) = match result {
8012                    Ok(result) => result,
8013                    Err(error) => {
8014                        self.rollback_observation("syncular_evict");
8015                        return Err(error);
8016                    }
8017                };
8018                if !deferred {
8019                    if let Err(error) = self.delete_pending_evict(&sub_id) {
8020                        self.rollback_observation("syncular_evict");
8021                        return Err(error);
8022                    }
8023                }
8024                let mut batch = ChangeAccumulator::default();
8025                self.record_scope_map(&mut batch, &table_name, &effective);
8026                if let Err(error) = self.finish_observation("syncular_evict", batch) {
8027                    self.rollback_observation("syncular_evict");
8028                    return Err(error);
8029                }
8030                if !remaining {
8031                    break;
8032                }
8033            }
8034        }
8035        Ok(())
8036    }
8037
8038    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
8039    /// commit — rows that MUST NOT be evicted until the commit drains.
8040    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
8041        let mut pinned = std::collections::HashSet::new();
8042        for commit in &self.outbox {
8043            for op in &commit.ops {
8044                if op.table == table {
8045                    pinned.insert(op.row_id.clone());
8046                }
8047            }
8048        }
8049        pinned
8050    }
8051
8052    fn save_pending_evict(
8053        &self,
8054        sub_id: &str,
8055        table: &str,
8056        effective: &[(String, Vec<String>)],
8057    ) -> Result<(), String> {
8058        self.conn.execute(
8059            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
8060            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
8061        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8062    }
8063
8064    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
8065        self.conn
8066            .execute(
8067                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
8068                rusqlite::params![sub_id],
8069            )
8070            .map(|_| ())
8071            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8072    }
8073
8074    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
8075        let mut stmt = self
8076            .conn
8077            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
8078            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8079        let rows = stmt
8080            .query_map([], |row| {
8081                Ok((
8082                    row.get::<_, String>(0)?,
8083                    row.get::<_, String>(1)?,
8084                    row.get::<_, String>(2)?,
8085                ))
8086            })
8087            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8088        rows.map(|row| {
8089            let (sub_id, table, json) =
8090                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8091            let value: Value = serde_json::from_str(&json)
8092                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8093            let effective = json_to_scope_map(&value)
8094                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8095            Ok((sub_id, table, effective))
8096        })
8097        .collect()
8098    }
8099
8100    /// Record one atomic local commit (§7.1) and apply it optimistically.
8101    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
8102        if mutations.is_empty() {
8103            return Err("a commit must contain at least one operation (§6.1)".to_owned());
8104        }
8105        let mut ops = Vec::with_capacity(mutations.len());
8106        for mutation in mutations {
8107            match mutation {
8108                Mutation::Upsert {
8109                    table,
8110                    values,
8111                    base_version,
8112                } => {
8113                    let schema_table = self
8114                        .schema
8115                        .table(&table)
8116                        .ok_or_else(|| format!("unknown table {table:?}"))?;
8117                    // §5: value keys are accepted in snake_case AND the
8118                    // generated row types' camelCase; normalize to SQL truth
8119                    // before the pk lookup / codec see them.
8120                    let values = normalize_values_casing(schema_table, values)?;
8121                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8122                    // §6.7 `mutate` marks every column present: fill missing
8123                    // nullable columns with NULL before the payload is built.
8124                    let values = full_row_values(schema_table, values)?;
8125                    // §5.11: validate the payload encodes with the current
8126                    // codec. A key-selection or unknown-key failure is a
8127                    // durable rejection at the push seam (§10.3), never an
8128                    // author-time error. Full-row `mutate` presents every
8129                    // column, so no stored fallback is needed.
8130                    self.validate_author_encode(schema_table, &row_id, &values)?;
8131                    ops.push(OutboxOp {
8132                        upsert: true,
8133                        table,
8134                        row_id,
8135                        base_version,
8136                        values: Some(values),
8137                    });
8138                }
8139                Mutation::Delete {
8140                    table,
8141                    row_id,
8142                    base_version,
8143                } => {
8144                    if self.schema.table(&table).is_none() {
8145                        return Err(format!("unknown table {table:?}"));
8146                    }
8147                    ops.push(OutboxOp {
8148                        upsert: false,
8149                        table,
8150                        row_id,
8151                        base_version,
8152                        values: None,
8153                    });
8154                }
8155            }
8156        }
8157        self.record_outbox_commit(ops)
8158    }
8159
8160    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
8161        let commit = OutboxCommit {
8162            client_commit_id: uuid::Uuid::new_v4().to_string(),
8163            ops,
8164        };
8165        self.begin_observation("syncular_mutation")?;
8166        let mut batch = ChangeAccumulator::default();
8167        for op in &commit.ops {
8168            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
8169            if let Some(values) = &op.values {
8170                if let Some(table) = self.schema.table(&op.table) {
8171                    for scope in &table.scope_variables {
8172                        if let Some(Value::String(value)) = values.get(&scope.column) {
8173                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
8174                            precise = true;
8175                        }
8176                    }
8177                }
8178            }
8179            if !precise {
8180                batch.table(&op.table);
8181            }
8182        }
8183        if let Err(error) = self.persist_outbox_insert(&commit) {
8184            self.rollback_observation("syncular_mutation");
8185            return Err(error);
8186        }
8187        let was_dirty = self.overlay_dirty.get();
8188        // A clean overlay contains the FIFO fold of every previous commit, so
8189        // the new commit applies over it. §7.1: an operation that does not
8190        // apply (a secondary unique collision) fails the whole commit; the
8191        // savepoint rollback removes the outbox entry and every visible write.
8192        if was_dirty {
8193            self.rebuild_overlay();
8194        }
8195        if let Some(error) = commit
8196            .ops
8197            .iter()
8198            .find_map(|op| self.apply_outbox_op(op).err())
8199        {
8200            self.rollback_observation("syncular_mutation");
8201            self.overlay_dirty.set(was_dirty);
8202            return Err(error);
8203        }
8204        let id = commit.client_commit_id.clone();
8205        self.outbox.push(commit);
8206        batch.status = true;
8207        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
8208            self.rollback_observation("syncular_mutation");
8209            self.outbox.pop();
8210            self.overlay_dirty.set(was_dirty);
8211            return Err(error);
8212        }
8213        Ok(id)
8214    }
8215
8216    /// Record one §6.1 sparse upsert: the primary key plus the supplied
8217    /// non-scope columns are present; every other column is absent and stays
8218    /// untouched on the server and in the local overlay. The row need not be
8219    /// locally present — an absent local row leaves the overlay absent (§7.1)
8220    /// and the server answers per §5.2.
8221    pub fn patch(
8222        &mut self,
8223        table: &str,
8224        row_id: &str,
8225        partial: Map<String, Value>,
8226        base_version: Option<i64>,
8227    ) -> Result<String, String> {
8228        let schema_table = self
8229            .schema
8230            .table(table)
8231            .ok_or_else(|| format!("unknown table {table:?}"))?;
8232        let partial = normalize_values_casing(schema_table, partial)?;
8233        if let Some(pk) = partial.get(&schema_table.primary_key) {
8234            if pk.as_str() != Some(row_id) {
8235                return Err(format!(
8236                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
8237                ));
8238            }
8239        }
8240        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
8241        // server accepts a present scope column whose value equals the
8242        // stored row and applies it as a no-op, so a decoded envelope
8243        // round-trips. The local row is the only value the client can prove
8244        // equality against; when the row is absent locally the client cannot
8245        // prove equality against the server's stored row and fails closed.
8246        // A primary key that is also a scope column needs no stored-row
8247        // read: the primary key in a sparse payload is by construction the
8248        // row id being patched (§6.1), so its value is proven equal already.
8249        for scope in &schema_table.scope_variables {
8250            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
8251                continue;
8252            }
8253            let Some(column) = schema_table
8254                .columns
8255                .iter()
8256                .find(|column| column.name == scope.column)
8257            else {
8258                continue;
8259            };
8260            let stored = self
8261                .conn
8262                .query_row(
8263                    &format!(
8264                        "SELECT {} FROM {} WHERE {}",
8265                        quote_ident(&scope.column),
8266                        visible_table(&schema_table.name),
8267                        row_id_predicate(schema_table)
8268                    ),
8269                    rusqlite::params![row_id],
8270                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
8271                )
8272                .ok();
8273            // Decode the supplied value through the same column-type path the
8274            // rest of the patch encoding uses, so a value that only equals
8275            // the stored one in another number representation still compares
8276            // equal.
8277            let incoming = json_to_column_value(column, partial.get(&scope.column))
8278                .ok()
8279                .map(|value| column_value_to_json(&value));
8280            // A stored blob never equals a patch value, matching the server's
8281            // scope-column comparison (§3.4): bytes are unstorable as scope
8282            // values.
8283            let equal = stored.as_ref().is_some_and(|stored| {
8284                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
8285            });
8286            if !equal {
8287                return Err(format!(
8288                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
8289                    scope.column
8290                ));
8291            }
8292        }
8293        let mut values = partial;
8294        // Presence-set semantics: a proven-equal scope column is dropped, so
8295        // the patch leaves the stored value untouched (§6.2).
8296        for scope in &schema_table.scope_variables {
8297            values.remove(&scope.column);
8298        }
8299        values.insert(
8300            schema_table.primary_key.clone(),
8301            Value::from(row_id.to_owned()),
8302        );
8303        // §5.11: structural validation only; an absent key-id selector is
8304        // resolved at the push seam from the stored local row, and a
8305        // key-selection or unknown-key failure becomes a durable
8306        // `client.encrypt_failed` rejection there (§10.3), never an
8307        // author-time error.
8308        self.validate_author_encode(schema_table, row_id, &values)?;
8309        self.record_outbox_commit(vec![OutboxOp {
8310            upsert: true,
8311            table: table.to_owned(),
8312            row_id: row_id.to_owned(),
8313            base_version,
8314            values: Some(values),
8315        }])
8316    }
8317
8318    /// §5.11 key-id fallback: the stored local row's selector value as a
8319    /// positional row (only the selector slot filled). Returns None when no
8320    /// selector is configured, the row is locally absent, or the stored
8321    /// value is not a usable key id.
8322    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
8323        let selector = self.encryption.key_id_columns.get(&table.name)?;
8324        let index = table
8325            .columns
8326            .iter()
8327            .position(|column| &column.name == selector)?;
8328        let sql = format!(
8329            "SELECT {} FROM {} WHERE {}",
8330            quote_ident(selector),
8331            visible_table(&table.name),
8332            row_id_predicate(table)
8333        );
8334        let key: Option<String> = self
8335            .conn
8336            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
8337            .ok()?;
8338        let key = key.filter(|key| !key.is_empty())?;
8339        let mut fallback: Row = vec![None; table.columns.len()];
8340        fallback[index] = Some(ColumnValue::String(key));
8341        Some(fallback)
8342    }
8343
8344    /// §5.11/§10.3 author-time encode check: a structural failure (a
8345    /// non-nullable NULL, a value the codec rejects) is an author-time
8346    /// error; a key-selection or unknown-key failure is deferred to the push
8347    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
8348    fn validate_author_encode(
8349        &self,
8350        table: &TableSchema,
8351        row_id: &str,
8352        values: &Map<String, Value>,
8353    ) -> Result<(), String> {
8354        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
8355            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
8356            result => result.map(|_| ()),
8357        }
8358    }
8359
8360    /// §5.11/§10.3: drop every pending commit whose push encode fails with
8361    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
8362    /// raise one durable rejection per commit. `sync()` never aborts for an
8363    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
8364    /// undoing the dropped commit's purely-optimistic rows.
8365    fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
8366        if !self
8367            .schema
8368            .tables
8369            .iter()
8370            .any(|table| table.has_encrypted_columns())
8371        {
8372            return Ok(false);
8373        }
8374        let failures = self
8375            .outbox
8376            .iter()
8377            .filter_map(|commit| {
8378                commit
8379                    .ops
8380                    .iter()
8381                    .find_map(|op| {
8382                        let values = op.values.as_ref().filter(|_| op.upsert)?;
8383                        let table = self.schema.table(&op.table)?;
8384                        let fallback = self.stored_key_fallback(table, &op.row_id);
8385                        match encode_sparse_row_json(
8386                            table,
8387                            &op.row_id,
8388                            values,
8389                            &self.encryption,
8390                            fallback.as_ref(),
8391                        ) {
8392                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
8393                            _ => None,
8394                        }
8395                    })
8396                    .map(|error| (commit.clone(), error))
8397            })
8398            .collect::<Vec<_>>();
8399        if failures.is_empty() {
8400            return Ok(false);
8401        }
8402        for (commit, message) in failures {
8403            let rejection = RejectionRecord {
8404                client_commit_id: commit.client_commit_id.clone(),
8405                op_index: 0,
8406                code: ENCRYPT_FAILED_CODE.to_owned(),
8407                message,
8408                retryable: false,
8409                details: None,
8410                operation: commit.ops.first().map(CommitOperation::from),
8411            };
8412            self.persist_commit_outcome(
8413                &commit.client_commit_id,
8414                CommitOutcomeStatus::Rejected,
8415                &[CommitOperationOutcome::Error {
8416                    rejection: rejection.clone(),
8417                }],
8418                Some(&commit.ops),
8419            )?;
8420            self.delete_outbox_persisted(&commit.client_commit_id)?;
8421            self.outbox
8422                .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
8423            self.rejections.push(rejection);
8424        }
8425        self.prune_commit_outcomes()?;
8426        self.overlay_dirty.set(true);
8427        self.rebuild_overlay();
8428        Ok(true)
8429    }
8430
8431    pub fn pending_commit_ids(&self) -> Vec<String> {
8432        self.outbox
8433            .iter()
8434            .map(|c| c.client_commit_id.clone())
8435            .collect()
8436    }
8437
8438    /// §6.1: the sparse push payload bytes of every pending upsert operation,
8439    /// FIFO — exactly what the next push round would put on the wire. Used by
8440    /// conformance B.21(h) to pin cross-core byte identity.
8441    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
8442        let mut payloads = Vec::new();
8443        for commit in &self.outbox {
8444            for op in &commit.ops {
8445                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
8446                    continue;
8447                };
8448                let Some(table) = self.schema.table(&op.table) else {
8449                    continue;
8450                };
8451                let fallback = self.stored_key_fallback(table, &op.row_id);
8452                if let Ok(payload) = encode_sparse_row_json(
8453                    table,
8454                    &op.row_id,
8455                    values,
8456                    &self.encryption,
8457                    fallback.as_ref(),
8458                ) {
8459                    payloads.push(payload);
8460                }
8461            }
8462        }
8463        payloads
8464    }
8465
8466    pub fn conflicts(&self) -> &[ConflictRecord] {
8467        &self.conflicts
8468    }
8469
8470    pub fn rejections(&self) -> &[RejectionRecord] {
8471        &self.rejections
8472    }
8473
8474    pub fn resolve_commit_outcome(
8475        &mut self,
8476        input: ResolveCommitOutcomeInput,
8477    ) -> Result<CommitOutcome, String> {
8478        let current = self
8479            .commit_outcome(&input.client_commit_id)?
8480            .ok_or_else(|| {
8481                format!(
8482                    "sync.outcome_not_found: no durable outcome exists for {:?}",
8483                    input.client_commit_id
8484                )
8485            })?;
8486        if current.resolution != CommitOutcomeResolution::Active {
8487            return Ok(current);
8488        }
8489        if input.resolution == CommitOutcomeResolution::Active {
8490            return Err("sync.invalid_request: resolution must leave active state".to_owned());
8491        }
8492        match input.resolution {
8493            CommitOutcomeResolution::Superseded => {
8494                let replacement = input
8495                    .replacement_client_commit_id
8496                    .as_deref()
8497                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8498                    .ok_or_else(|| {
8499                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8500                            .to_owned()
8501                    })?;
8502                let _ = replacement;
8503            }
8504            _ if input.replacement_client_commit_id.is_some() => {
8505                return Err(
8506                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8507                        .to_owned(),
8508                );
8509            }
8510            _ => {}
8511        }
8512        let allowed = match current.status {
8513            CommitOutcomeStatus::Conflict => matches!(
8514                input.resolution,
8515                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8516            ),
8517            CommitOutcomeStatus::Rejected => {
8518                input.resolution == CommitOutcomeResolution::Superseded
8519            }
8520            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8521                input.resolution == CommitOutcomeResolution::Dismissed
8522            }
8523        };
8524        if !allowed {
8525            return Err(format!(
8526                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8527                input.resolution, current.status
8528            ));
8529        }
8530
8531        self.begin_observation("syncular_outcome_resolution")?;
8532        let result = (|| {
8533            self.conn
8534                .execute(
8535                    "UPDATE _syncular_commit_outcomes
8536                        SET resolution = ?1, resolved_at_ms = ?2,
8537                            replacement_client_commit_id = ?3
8538                      WHERE client_commit_id = ?4 AND resolution = 'active'",
8539                    rusqlite::params![
8540                        Self::outcome_resolution_name(input.resolution),
8541                        self.clock_now_ms(),
8542                        input.replacement_client_commit_id,
8543                        input.client_commit_id
8544                    ],
8545                )
8546                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8547            let resolved = self
8548                .commit_outcome(&current.client_commit_id)?
8549                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8550            self.prune_commit_outcomes()?;
8551            Ok(resolved)
8552        })();
8553        let resolved = match result {
8554            Ok(outcome) => outcome,
8555            Err(error) => {
8556                self.rollback_observation("syncular_outcome_resolution");
8557                return Err(error);
8558            }
8559        };
8560        self.conflicts
8561            .retain(|record| record.client_commit_id != current.client_commit_id);
8562        self.rejections
8563            .retain(|record| record.client_commit_id != current.client_commit_id);
8564        let batch = ChangeAccumulator {
8565            conflicts: current.status == CommitOutcomeStatus::Conflict,
8566            rejections: current.status == CommitOutcomeStatus::Rejected,
8567            outcomes: true,
8568            ..ChangeAccumulator::default()
8569        };
8570        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8571            self.rollback_observation("syncular_outcome_resolution");
8572            return Err(error);
8573        }
8574        Ok(resolved)
8575    }
8576
8577    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8578        self.schema_floor.as_ref()
8579    }
8580
8581    /// §7.3.5: the client's opaque auth-lease state, if any.
8582    pub fn lease_state(&self) -> Option<&LeaseState> {
8583        self.lease_state.as_ref()
8584    }
8585
8586    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
8587    /// the two lease codes set it; other errors leave leaseState untouched.
8588    fn record_lease_error(&mut self, code: &str) {
8589        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8590            return;
8591        }
8592        let mut next = self.lease_state.clone().unwrap_or_default();
8593        next.error_code = Some(code.to_owned());
8594        self.set_lease_state(Some(next));
8595    }
8596
8597    fn set_lease_state(&mut self, next: Option<LeaseState>) {
8598        if self.lease_state == next {
8599            return;
8600        }
8601        if self.begin_observation("syncular_lease").is_err() {
8602            return;
8603        }
8604        self.lease_state = next;
8605        if let Some(lease) = &self.lease_state {
8606            if let Ok(json) = serde_json::to_string(lease) {
8607                self.set_meta(LEASE_STATE_KEY, &json);
8608            }
8609        } else {
8610            self.delete_meta(LEASE_STATE_KEY);
8611        }
8612        let batch = ChangeAccumulator {
8613            status: true,
8614            ..ChangeAccumulator::default()
8615        };
8616        if self.finish_observation("syncular_lease", batch).is_err() {
8617            self.rollback_observation("syncular_lease");
8618        }
8619    }
8620
8621    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
8622        if self.schema_floor == next {
8623            return;
8624        }
8625        if self.begin_observation("syncular_schema_floor").is_err() {
8626            return;
8627        }
8628        self.schema_floor = next;
8629        self.stopped = self.schema_floor.is_some();
8630        if let Some(floor) = &self.schema_floor {
8631            if let Ok(json) = serde_json::to_string(floor) {
8632                self.set_meta(SCHEMA_FLOOR_KEY, &json);
8633            }
8634        } else {
8635            self.delete_meta(SCHEMA_FLOOR_KEY);
8636        }
8637        let batch = ChangeAccumulator {
8638            status: true,
8639            ..ChangeAccumulator::default()
8640        };
8641        if self
8642            .finish_observation("syncular_schema_floor", batch)
8643            .is_err()
8644        {
8645            self.rollback_observation("syncular_schema_floor");
8646        }
8647    }
8648
8649    fn set_upgrading(&mut self, value: bool) {
8650        if self.upgrading == value {
8651            return;
8652        }
8653        if self.begin_observation("syncular_upgrading").is_err() {
8654            return;
8655        }
8656        self.upgrading = value;
8657        let batch = ChangeAccumulator {
8658            status: true,
8659            ..ChangeAccumulator::default()
8660        };
8661        if self
8662            .finish_observation("syncular_upgrading", batch)
8663            .is_err()
8664        {
8665            self.rollback_observation("syncular_upgrading");
8666        }
8667    }
8668
8669    pub fn sync_needed(&self) -> bool {
8670        self.sync_needed
8671    }
8672
8673    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
8674        let sub = self.subs.iter().find(|s| s.id == id)?;
8675        Some(SubscriptionStateView {
8676            id: sub.id.clone(),
8677            table: sub.table.clone(),
8678            status: sub.state.name().to_owned(),
8679            cursor: sub.cursor,
8680            has_resume_token: sub.bootstrap_state.is_some(),
8681            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8682            reason_code: sub.reason_code.clone(),
8683        })
8684    }
8685
8686    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
8687        let schema_table = self
8688            .schema
8689            .table(table)
8690            .ok_or_else(|| format!("unknown table {table:?}"))?;
8691        let sql = format!(
8692            "SELECT * FROM {} ORDER BY {} ASC",
8693            visible_table(table),
8694            quote_ident(&schema_table.primary_key)
8695        );
8696        let mut stmt = self
8697            .conn
8698            .prepare(&sql)
8699            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8700        let mut rows = stmt
8701            .query([])
8702            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8703        let mut out = Vec::new();
8704        while let Some(row) = rows
8705            .next()
8706            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8707        {
8708            let mut values = Map::new();
8709            for (i, column) in schema_table.columns.iter().enumerate() {
8710                let value = row
8711                    .get_ref(i)
8712                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8713                values.insert(column.name.clone(), sql_ref_to_json(column, value));
8714            }
8715            let version: i64 = row
8716                .get(schema_table.columns.len())
8717                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8718            let row_id = match values.get(&schema_table.primary_key) {
8719                Some(Value::String(s)) => s.clone(),
8720                Some(Value::Number(n)) => n.to_string(),
8721                Some(Value::Bool(b)) => b.to_string(),
8722                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
8723            };
8724            out.push(RowState {
8725                row_id,
8726                version,
8727                values,
8728            });
8729        }
8730        Ok(out)
8731    }
8732
8733    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
8734    //
8735    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
8736    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
8737    // the op with `yrs`, re-encodes the whole doc state, and records a
8738    // baseVersion-less sparse upsert carrying only the primary key and the
8739    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
8740    // the overlay re-materializes the edit immediately (optimistic apply,
8741    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
8742    // Byte-compatible with `@syncular/crdt-yjs`.
8743
8744    /// The current stored value of a `crdt` column for one row — the visible
8745    /// (optimistic) bytes, or `None` when the row is absent or the column is
8746    /// NULL (the empty document, §5.10.1). Errors if the column is not a
8747    /// `crdt` column (guards the app against a typo'd column name).
8748    #[cfg(feature = "crdt-yjs")]
8749    fn crdt_column_bytes(
8750        &self,
8751        table: &str,
8752        row_id: &str,
8753        column: &str,
8754    ) -> Result<Option<Vec<u8>>, String> {
8755        let schema_table = self
8756            .schema
8757            .table(table)
8758            .ok_or_else(|| format!("unknown table {table:?}"))?;
8759        let col = schema_table
8760            .columns
8761            .iter()
8762            .find(|c| c.name == column)
8763            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
8764        if col.ty != ColumnType::Crdt {
8765            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
8766        }
8767        let sql = format!(
8768            "SELECT {} FROM {} WHERE {}",
8769            quote_ident(column),
8770            visible_table(table),
8771            row_id_predicate(schema_table)
8772        );
8773        let bytes: Option<Vec<u8>> = self
8774            .conn
8775            .prepare_cached(&sql)
8776            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8777            .query_row(rusqlite::params![row_id], |row| {
8778                row.get::<_, Option<Vec<u8>>>(0)
8779            })
8780            .map_err(|e| match e {
8781                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
8782                other => other.to_string(),
8783            })?;
8784        Ok(bytes)
8785    }
8786
8787    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
8788    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
8789    /// An absent row / NULL column is the empty document (empty string).
8790    #[cfg(feature = "crdt-yjs")]
8791    pub fn crdt_text(
8792        &self,
8793        table: &str,
8794        row_id: &str,
8795        column: &str,
8796        name: &str,
8797    ) -> Result<String, String> {
8798        let bytes = self
8799            .crdt_column_bytes(table, row_id, column)?
8800            .unwrap_or_default();
8801        crate::crdt::text(&bytes, name)
8802    }
8803
8804    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
8805    /// record the resulting crdt-only sparse operation. Returns the enqueued
8806    /// `clientCommitId`.
8807    #[cfg(feature = "crdt-yjs")]
8808    pub fn crdt_insert_text(
8809        &mut self,
8810        table: &str,
8811        row_id: &str,
8812        column: &str,
8813        name: &str,
8814        index: u32,
8815        value: &str,
8816    ) -> Result<String, String> {
8817        let current = self
8818            .crdt_column_bytes(table, row_id, column)?
8819            .unwrap_or_default();
8820        let update = crate::crdt::insert_text(&current, name, index, value)?;
8821        self.crdt_push_update(table, row_id, column, &update)
8822    }
8823
8824    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
8825    /// record the resulting crdt-only sparse operation. Returns the enqueued
8826    /// `clientCommitId`.
8827    #[cfg(feature = "crdt-yjs")]
8828    pub fn crdt_delete_text(
8829        &mut self,
8830        table: &str,
8831        row_id: &str,
8832        column: &str,
8833        name: &str,
8834        index: u32,
8835        len: u32,
8836    ) -> Result<String, String> {
8837        let current = self
8838            .crdt_column_bytes(table, row_id, column)?
8839            .unwrap_or_default();
8840        let update = crate::crdt::delete_text(&current, name, index, len)?;
8841        self.crdt_push_update(table, row_id, column, &update)
8842    }
8843
8844    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
8845    /// `crdt` column's current state and record the resulting crdt-only sparse
8846    /// operation. The app authored the update with its own `yrs` model.
8847    /// Returns the enqueued `clientCommitId`.
8848    #[cfg(feature = "crdt-yjs")]
8849    pub fn crdt_apply_update(
8850        &mut self,
8851        table: &str,
8852        row_id: &str,
8853        column: &str,
8854        update: &[u8],
8855    ) -> Result<String, String> {
8856        let current = self
8857            .crdt_column_bytes(table, row_id, column)?
8858            .unwrap_or_default();
8859        let next = crate::crdt::apply_update(&current, update)?;
8860        self.crdt_push_update(table, row_id, column, &next)
8861    }
8862
8863    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
8864    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
8865    /// enqueue it. The server merges the crdt column and leaves every other
8866    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
8867    /// clobber a concurrent edit to another column. A locally absent row
8868    /// records the same partial operation; the server answers per §6.2.
8869    #[cfg(feature = "crdt-yjs")]
8870    fn crdt_push_update(
8871        &mut self,
8872        table: &str,
8873        row_id: &str,
8874        column: &str,
8875        crdt_bytes: &[u8],
8876    ) -> Result<String, String> {
8877        let mut bytes_obj = Map::new();
8878        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
8879        let mut partial = Map::new();
8880        partial.insert(column.to_owned(), Value::Object(bytes_obj));
8881        self.patch(table, row_id, partial, None)
8882    }
8883
8884    /// Run an arbitrary read-only SQL query against the local database and
8885    /// return each row as a `column-name → JSON value` map. This is the seam
8886    /// the React `useSyncQuery` live-query API needs (it takes app-authored
8887    /// SQL over the visible tables/views, not a fixed table read like
8888    /// [`read_rows`]).
8889    ///
8890    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
8891    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
8892    /// same envelope the command surface uses everywhere else. Output BLOB
8893    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
8894    ///
8895    /// The result column typing is dynamic (SQLite's stored affinity), because
8896    /// arbitrary SQL can alias, join, and compute — there is no schema column
8897    /// to consult per output cell, unlike [`read_rows`].
8898    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
8899        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
8900    }
8901
8902    /// Repository benchmark and conformance access, present only with `bench-internals`.
8903    #[cfg(feature = "bench-internals")]
8904    #[doc(hidden)]
8905    pub fn benchmark_connection(&mut self) -> &mut Connection {
8906        &mut self.conn
8907    }
8908
8909    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
8910    #[cfg(feature = "bench-internals")]
8911    #[doc(hidden)]
8912    pub fn benchmark_phases(
8913        &self,
8914        enabled: Option<bool>,
8915        reset: bool,
8916    ) -> Result<Option<Value>, String> {
8917        self.benchmark_phases.configure(enabled, reset)?;
8918        Ok(self.benchmark_phases.snapshot())
8919    }
8920
8921    /// Rows, coverage, and local revision from one SQLite read snapshot.
8922    /// §7.5 atomic snapshot read. An `owner` records a failure in
8923    /// `diagnostics_snapshot().query_failures` until its next successful read.
8924    pub fn query_snapshot(
8925        &mut self,
8926        sql: &str,
8927        params: &[QueryValue],
8928        coverage: &[WindowCoverage],
8929        owner: Option<QueryOwner<'_>>,
8930    ) -> Result<QuerySnapshot, String> {
8931        let result = snapshot_connection(&self.conn, sql, params, coverage);
8932        if let Some(owner) = owner {
8933            self.record_query_read(owner, result.as_ref().err());
8934        }
8935        result.map_err(|failure| failure.to_string())
8936    }
8937
8938    /// §7.6: record the outcome of one owned snapshot read, including reads a
8939    /// host ran on a separate connection. A success removes the owner's entry;
8940    /// an identical failure leaves it unchanged; a changed failure moves last.
8941    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
8942        let position = self
8943            .query_failures
8944            .iter()
8945            .position(|entry| entry.id == owner.id);
8946        let Some(failure) = failure else {
8947            if let Some(index) = position {
8948                self.query_failures.remove(index);
8949            }
8950            return;
8951        };
8952        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
8953            .into_iter()
8954            .map(str::to_owned)
8955            .collect();
8956        let code = failure.code.unwrap_or("client.query_failed").to_owned();
8957        if position.is_some_and(|index| {
8958            let entry = &self.query_failures[index];
8959            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
8960        }) {
8961            return;
8962        }
8963        if let Some(index) = position {
8964            self.query_failures.remove(index);
8965        }
8966        self.query_failures.push(DiagnosticQueryFailure {
8967            id: owner.id.to_owned(),
8968            tables,
8969            code,
8970            sqlite_code: failure.sqlite_code,
8971            at_ms: self.clock_now_ms(),
8972        });
8973        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
8974            self.query_failures.remove(0);
8975        }
8976    }
8977
8978    // -- request building ---------------------------------------------------------
8979
8980    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
8981        #[cfg(feature = "bench-internals")]
8982        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
8983        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
8984        let mut frames = vec![Frame::ReqHeader {
8985            client_id: self.client_id.clone(),
8986            schema_version: self.schema.version,
8987            log_epoch: log_epoch.clone(),
8988        }];
8989        let mut pushed_ids = Vec::new();
8990        let mut ops_in_request = 0usize;
8991        let mut deferred_commits = 0usize;
8992        #[cfg(feature = "bench-internals")]
8993        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
8994        for (index, commit) in self
8995            .outbox
8996            .iter()
8997            .take(if log_epoch.is_some() {
8998                self.outbox.len()
8999            } else {
9000                0
9001            })
9002            .enumerate()
9003        {
9004            // §6.1 splitBatch: stop at the operation cap — commits apply in
9005            // order, so everything from the first non-fitting commit on is
9006            // deferred to the next round. A single over-cap commit still goes
9007            // alone (commits are atomic and cannot be split).
9008            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
9009                deferred_commits = self.outbox.len() - index;
9010                break;
9011            }
9012            ops_in_request += commit.ops.len();
9013            let operations = commit
9014                .ops
9015                .iter()
9016                .map(|op| {
9017                    let payload = op.values.as_ref().and_then(|values| {
9018                        let table = self.schema.table(&op.table)?;
9019                        // §0: outbox entries encode at send time with the
9020                        // current codec (validated at mutate()). §6.1: the
9021                        // values map's keys are the sparse presence set.
9022                        // §5.11: encrypted columns are encrypted here, with
9023                        // an absent key-id selector read from the stored row.
9024                        // `sync_inner` runs `drop_unencodable_outbox` first,
9025                        // so an encrypt failure cannot reach this encode; the
9026                        // author seam validated every other failure.
9027                        let fallback = self.stored_key_fallback(table, &op.row_id);
9028                        encode_sparse_row_json(
9029                            table,
9030                            &op.row_id,
9031                            values,
9032                            &self.encryption,
9033                            fallback.as_ref(),
9034                        )
9035                        .ok()
9036                    });
9037                    ssp2::model::Operation {
9038                        table: op.table.clone(),
9039                        row_id: op.row_id.clone(),
9040                        op: if op.upsert { Op::Upsert } else { Op::Delete },
9041                        base_version: op.base_version,
9042                        payload,
9043                    }
9044                })
9045                .collect();
9046            frames.push(Frame::PushCommit {
9047                client_commit_id: commit.client_commit_id.clone(),
9048                operations,
9049            });
9050            pushed_ids.push(commit.client_commit_id.clone());
9051        }
9052        #[cfg(feature = "bench-internals")]
9053        drop(outbox_phase);
9054        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
9055        // bare URL — capability negotiation, decided per transport.
9056        let accept = self.limits.accept.unwrap_or(if url_capable {
9057            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
9058        } else {
9059            DEFAULT_ACCEPT
9060        });
9061        frames.push(Frame::PullHeader {
9062            limit_commits: self.limits.limit_commits.unwrap_or(0),
9063            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
9064            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
9065            accept,
9066        });
9067        let mut fresh = Vec::new();
9068        for sub in &self.subs {
9069            if sub.state != SubState::Active {
9070                continue;
9071            }
9072            let mut scopes = sub.requested.clone();
9073            sort_scope_map(&mut scopes);
9074            frames.push(Frame::Subscription {
9075                id: sub.id.clone(),
9076                table: sub.table.clone(),
9077                scopes,
9078                params: sub.params.clone().map(RawJson),
9079                cursor: sub.cursor,
9080                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
9081            });
9082            fresh.push((
9083                sub.id.clone(),
9084                sub.cursor < 0 && sub.bootstrap_state.is_none(),
9085            ));
9086        }
9087        let message = Message {
9088            wire_version: WIRE_VERSION,
9089            msg_kind: MsgKind::Request,
9090            frames,
9091        };
9092        (
9093            message,
9094            RequestMeta {
9095                pushed_ids,
9096                fresh,
9097                accept,
9098                deferred_commits,
9099            },
9100        )
9101    }
9102
9103    // -- sync -------------------------------------------------------------------
9104
9105    /// Cloneable observation handle; no client lock is needed to read it.
9106    pub fn progress(&self) -> ProgressObserver {
9107        self.progress.clone()
9108    }
9109
9110    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9111        self.storage_failure.borrow_mut().take();
9112        self.progress.start();
9113        self.round_retry_delay_ms = None;
9114        let started_at_ms = self.clock_now_ms();
9115        let mut outcome = self.sync_inner(transport);
9116        if let SyncOutcome::Failed {
9117            error_code,
9118            message,
9119            details,
9120        } = &mut outcome
9121        {
9122            if let Some(failure) = self.storage_failure.borrow().as_ref() {
9123                let code = failure
9124                    .code
9125                    .expect("retained storage failures are classified");
9126                *error_code = code.to_owned();
9127                *message = match code {
9128                    "client.storage_full" => "local SQLite storage is full",
9129                    "client.storage_io" => "local SQLite storage I/O failed",
9130                    "client.storage_corrupt" => "local SQLite storage is corrupt",
9131                    _ => unreachable!("unrecognized classified storage failure"),
9132                }
9133                .to_owned();
9134                *details = failure.details();
9135            }
9136        }
9137        let retry_delay_ms = self.round_retry_delay_ms;
9138        self.progress.update(|p| match &outcome {
9139            SyncOutcome::Ok(report) if report.failed.is_empty() => {
9140                p.state = ProgressState::Complete
9141            }
9142            SyncOutcome::Ok(_) => {
9143                p.state = ProgressState::Failed;
9144                p.error_code = Some("sync.scope_revoked".into());
9145            }
9146            SyncOutcome::Failed { error_code, .. } => {
9147                p.state = ProgressState::Failed;
9148                p.error_code = Some(Self::diagnostic_code(error_code));
9149                p.retry_delay_ms = retry_delay_ms;
9150            }
9151            SyncOutcome::RealtimeUnavailable { .. } => {
9152                p.state = ProgressState::Failed;
9153                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
9154                p.retry_delay_ms = retry_delay_ms;
9155            }
9156        });
9157        let completed_at_ms = self.clock_now_ms();
9158        self.last_round = Some(match &outcome {
9159            SyncOutcome::Ok(report) => DiagnosticLastRound {
9160                status: "succeeded".to_owned(),
9161                started_at_ms,
9162                completed_at_ms,
9163                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9164                counters: Some(DiagnosticRoundCounters {
9165                    pushed: report.pushed,
9166                    applied: report.applied.len(),
9167                    rejected: report.rejected.len(),
9168                    retryable: report.retryable.len(),
9169                    conflicts: report.conflicts,
9170                    commits_applied: report.commits_applied,
9171                    segment_rows_applied: report.segment_rows_applied,
9172                    bootstrapping: report.bootstrapping.len(),
9173                    resets: report.resets.len(),
9174                    revoked: report.revoked.len(),
9175                    failed: report.failed.len(),
9176                    deferred_commits: report.deferred_commits,
9177                }),
9178                error_code: None,
9179            },
9180            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
9181                status: "failed".to_owned(),
9182                started_at_ms,
9183                completed_at_ms,
9184                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9185                counters: None,
9186                error_code: Some(Self::diagnostic_code(error_code)),
9187            },
9188            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
9189                status: "failed".to_owned(),
9190                started_at_ms,
9191                completed_at_ms,
9192                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9193                counters: None,
9194                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
9195            },
9196        });
9197        outcome
9198    }
9199
9200    fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9201        if self.stopped {
9202            // §1.6: the client stopped at the schema floor; syncing is inert
9203            // until an upgrade. The outbox is preserved for replay.
9204            return SyncOutcome::Ok(SyncReport {
9205                schema_floor: self.schema_floor.clone(),
9206                ..SyncReport::default()
9207            });
9208        }
9209        // §8.4: the coalesced sync-needed signal clears when a pull round
9210        // BEGINS, so a wake-up landing mid-round survives it.
9211        self.set_sync_needed(false, false);
9212        // §5.9.7 B4: upload pending blobs before pushing the referencing
9213        // rows, so the server-side existence check (§6.6) passes.
9214        if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
9215            if let Err(TransportError { code, message, .. }) = self.flush_blob_uploads(transport) {
9216                if Self::retryable_failure_code(&code) {
9217                    self.schedule_background_retry();
9218                }
9219                return SyncOutcome::Failed {
9220                    details: None,
9221                    error_code: code,
9222                    message,
9223                };
9224            }
9225        }
9226        if let Err(message) = self.drain_pending_evictions() {
9227            return SyncOutcome::Failed {
9228                details: None,
9229                error_code: "storage.failed".into(),
9230                message,
9231            };
9232        }
9233        // §5.11/§10.3: an unresolvable key id or an unknown key is a durable
9234        // per-commit rejection, never a reason to abort the round. Drop those
9235        // commits before the request is built.
9236        if let Err(message) = self.drop_unencodable_outbox() {
9237            return SyncOutcome::Failed {
9238                details: None,
9239                error_code: "storage.failed".into(),
9240                message,
9241            };
9242        }
9243        let (message, meta) = self.build_request(transport.supports_url_fetch());
9244        let request_bytes = {
9245            #[cfg(feature = "bench-internals")]
9246            let _phase = self.benchmark_phases.start(Phase::RequestEncode);
9247            encode_message(&message)
9248        };
9249        // §8.7: rounds ride the socket whenever it is connected (one
9250        // loop, no fallback pair); the transport seam stays bytes-in /
9251        // bytes-out either way. Registration-at-round-end is server-side.
9252        // §8.8: under `required`, a round entered while the socket is not
9253        // connected is refused as a typed state and NEVER falls back to the
9254        // request/response binding.
9255        let round = if self.realtime_state == RealtimeState::Connected {
9256            transport.realtime_sync(&request_bytes)
9257        } else if self.realtime_policy == RealtimePolicy::Required {
9258            let retry_delay_ms = self.schedule_background_retry();
9259            self.realtime_retry_delay_ms = Some(retry_delay_ms);
9260            return SyncOutcome::RealtimeUnavailable {
9261                state: self.realtime_state,
9262                reason_code: self.realtime_reason_code.clone(),
9263                retry_delay_ms,
9264            };
9265        } else {
9266            transport.sync(&request_bytes)
9267        };
9268        let realtime_round = self.realtime_state == RealtimeState::Connected;
9269        let response_bytes = match round {
9270            Ok(bytes) => bytes,
9271            Err(TransportError { code, message, .. }) => {
9272                // §8.8: the socket could not carry the round. Record the loss
9273                // with the transport's code so a later round refuses under
9274                // `required` and reports the explicit state.
9275                if realtime_round {
9276                    self.set_realtime_state(RealtimeState::Lost, Some(&code));
9277                }
9278                // §7.3.5: a request-level lease code stops-and-surfaces —
9279                // record it in leaseState (no local-data purge, §7.3.4).
9280                self.record_lease_error(&code);
9281                if Self::retryable_failure_code(&code) {
9282                    let retry_delay_ms = self.schedule_background_retry();
9283                    if realtime_round {
9284                        self.realtime_retry_delay_ms = Some(retry_delay_ms);
9285                    }
9286                }
9287                return SyncOutcome::Failed {
9288                    details: None,
9289                    error_code: code,
9290                    message,
9291                };
9292            }
9293        };
9294        #[cfg(feature = "bench-internals")]
9295        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9296        let response = match decode_message(&response_bytes) {
9297            Ok(message) => message,
9298            Err(error) => {
9299                // §1.2 rule 1 / §1.4 rule 5: truncated or malformed
9300                // responses abort without persisting anything.
9301                return SyncOutcome::Failed {
9302                    details: None,
9303                    error_code: error.code.as_str().to_owned(),
9304                    message: error.detail,
9305                };
9306            }
9307        };
9308        #[cfg(feature = "bench-internals")]
9309        drop(decode_phase);
9310        if response.msg_kind != MsgKind::Response {
9311            return SyncOutcome::Failed {
9312                details: None,
9313                error_code: "sync.invalid_request".to_owned(),
9314                message: "expected a response message".to_owned(),
9315            };
9316        }
9317        let mut outcome = self.process_response(transport, response, &meta);
9318        if let SyncOutcome::Ok(report) = &mut outcome {
9319            report.deferred_commits = meta.deferred_commits;
9320        }
9321        match &outcome {
9322            SyncOutcome::Ok(_) => self.reset_background_retry(),
9323            SyncOutcome::Failed { error_code, .. } if Self::retryable_failure_code(error_code) => {
9324                self.schedule_background_retry();
9325            }
9326            SyncOutcome::Failed { .. } => {}
9327            SyncOutcome::RealtimeUnavailable { .. } => {}
9328        }
9329        if meta.deferred_commits > 0 {
9330            // §6.1 splitBatch: commits past the operation cap wait for the
9331            // next round — keep the host's sync signal raised until then.
9332            self.set_sync_needed(true, true);
9333        }
9334        // RFC 0005 D7: coverage completion, lease end, scope revocation and the
9335        // TTL are evaluated once per round, after the apply.
9336        self.previous_version_lifetime_check();
9337        outcome
9338    }
9339
9340    pub fn sync_until_idle(
9341        &mut self,
9342        transport: &mut dyn Transport,
9343        max_rounds: Option<u32>,
9344    ) -> SyncOutcome {
9345        let rounds = max_rounds.unwrap_or(20).max(1);
9346        let mut aggregate = SyncReport::default();
9347        let mut spent = 0;
9348        while spent < rounds {
9349            spent += 1;
9350            let before = self
9351                .subs
9352                .iter()
9353                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9354                .collect::<Vec<_>>();
9355            let outcome = self.sync(transport);
9356            match outcome {
9357                SyncOutcome::Failed {
9358                    error_code,
9359                    message,
9360                    details,
9361                } => {
9362                    return SyncOutcome::Failed {
9363                        details,
9364                        error_code,
9365                        message,
9366                    };
9367                }
9368                SyncOutcome::RealtimeUnavailable { .. } => return outcome,
9369                SyncOutcome::Ok(report) => {
9370                    if max_rounds.is_none()
9371                        && report.segment_rows_applied > 0
9372                        && !report.bootstrapping.is_empty()
9373                        && before
9374                            != self
9375                                .subs
9376                                .iter()
9377                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9378                                .collect::<Vec<_>>()
9379                    {
9380                        spent = 0;
9381                    }
9382                    aggregate.pushed += report.pushed;
9383                    aggregate.applied.extend(report.applied.iter().cloned());
9384                    aggregate.rejected.extend(report.rejected.iter().cloned());
9385                    aggregate.retryable.extend(report.retryable.iter().cloned());
9386                    aggregate.conflicts += report.conflicts;
9387                    aggregate.commits_applied += report.commits_applied;
9388                    aggregate.segment_rows_applied += report.segment_rows_applied;
9389                    aggregate.bootstrapping = report.bootstrapping.clone();
9390                    aggregate.resets.extend(report.resets.iter().cloned());
9391                    aggregate.revoked.extend(report.revoked.iter().cloned());
9392                    aggregate.failed.extend(report.failed.iter().cloned());
9393                    aggregate.deferred_commits = report.deferred_commits;
9394                    if report.schema_floor.is_some() {
9395                        aggregate.schema_floor = report.schema_floor.clone();
9396                    }
9397                    // §4.5: pull again whenever the response contained
9398                    // commits or segments; resets re-bootstrap; a pending
9399                    // resume token continues paging (§4.7); a raised
9400                    // sync-needed signal covers §6.1 splitBatch remainders
9401                    // (deferred outbox commits push on the next round).
9402                    let more = !report.bootstrapping.is_empty()
9403                        || report.commits_applied > 0
9404                        || report.segment_rows_applied > 0
9405                        || !report.resets.is_empty()
9406                        || self.sync_needed;
9407                    if !more {
9408                        return SyncOutcome::Ok(aggregate);
9409                    }
9410                }
9411            }
9412        }
9413        SyncOutcome::Failed {
9414            details: None,
9415            error_code: "sync.invalid_request".into(),
9416            message: "sync did not reach idle within the round budget".into(),
9417        }
9418    }
9419
9420    fn process_response(
9421        &mut self,
9422        transport: &mut dyn Transport,
9423        response: Message,
9424        meta: &RequestMeta,
9425    ) -> SyncOutcome {
9426        #[cfg(feature = "bench-internals")]
9427        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
9428        let mut report = SyncReport {
9429            pushed: meta.pushed_ids.len() as u32,
9430            ..SyncReport::default()
9431        };
9432        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
9433            HashMap::new();
9434        let pushed_ids = meta
9435            .pushed_ids
9436            .iter()
9437            .map(String::as_str)
9438            .collect::<HashSet<_>>();
9439        let mut last_final_push_result_id: Option<String> = None;
9440        for frame in &response.frames {
9441            match frame {
9442                Frame::PushResultDetails {
9443                    client_commit_id,
9444                    entries,
9445                } => {
9446                    let details = rejection_details_by_commit
9447                        .entry(client_commit_id.clone())
9448                        .or_default();
9449                    for entry in entries {
9450                        let parsed = match RejectionDetails::parse(&entry.details.0) {
9451                            Ok(value) => value,
9452                            Err(message) => {
9453                                return SyncOutcome::Failed {
9454                                    details: None,
9455                                    error_code: "sync.invalid_request".to_owned(),
9456                                    message,
9457                                };
9458                            }
9459                        };
9460                        details.insert(entry.op_index, parsed);
9461                    }
9462                }
9463                Frame::PushResult {
9464                    client_commit_id,
9465                    status,
9466                    results,
9467                    ..
9468                } if pushed_ids.contains(client_commit_id.as_str())
9469                    && Self::push_result_is_final(*status, results) =>
9470                {
9471                    last_final_push_result_id = Some(client_commit_id.clone());
9472                }
9473                _ => {}
9474            }
9475        }
9476        let mut frames = response.frames.into_iter().peekable();
9477        if response.wire_version < 2 {
9478            return SyncOutcome::Failed {
9479                details: None,
9480                error_code: "client.invalid_host_response".to_owned(),
9481                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
9482            };
9483        }
9484        match frames.next() {
9485            Some(Frame::RespHeader {
9486                required_schema_version,
9487                latest_schema_version,
9488                log_epoch,
9489                reset_required,
9490            }) => {
9491                let Some(log_epoch) = log_epoch else {
9492                    return SyncOutcome::Failed {
9493                        details: None,
9494                        error_code: "client.invalid_host_response".to_owned(),
9495                        message: "response header omits logEpoch".to_owned(),
9496                    };
9497                };
9498                let Some(reset_required) = reset_required else {
9499                    return SyncOutcome::Failed {
9500                        details: None,
9501                        error_code: "client.invalid_host_response".to_owned(),
9502                        message: "response header omits resetRequired".to_owned(),
9503                    };
9504                };
9505                if let Some(required) = required_schema_version {
9506                    // §1.6 schema-floor response: nothing else is processed;
9507                    // stop syncing and surface the upgrade requirement.
9508                    let floor = SchemaFloor {
9509                        required_schema_version: Some(required),
9510                        latest_schema_version,
9511                    };
9512                    self.set_schema_floor(Some(floor.clone()));
9513                    report.schema_floor = Some(floor);
9514                    return SyncOutcome::Ok(report);
9515                }
9516                if reset_required {
9517                    if frames.next().is_some() {
9518                        return SyncOutcome::Failed {
9519                            details: None,
9520                            error_code: "client.invalid_host_response".to_owned(),
9521                            message: "log-epoch reset response contains body frames".to_owned(),
9522                        };
9523                    }
9524                    match self.run_log_epoch_reset(&log_epoch) {
9525                        Ok(resets) => {
9526                            report.resets = resets;
9527                            return SyncOutcome::Ok(report);
9528                        }
9529                        Err(message) => {
9530                            return SyncOutcome::Failed {
9531                                details: None,
9532                                error_code: "sync.local_corrupt".to_owned(),
9533                                message,
9534                            };
9535                        }
9536                    }
9537                }
9538                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
9539                    return SyncOutcome::Failed {
9540                        details: None,
9541                        error_code: "client.invalid_host_response".to_owned(),
9542                        message: "server changed logEpoch without requiring a reset".to_owned(),
9543                    };
9544                }
9545            }
9546            _ => {
9547                return SyncOutcome::Failed {
9548                    details: None,
9549                    error_code: "sync.invalid_request".to_owned(),
9550                    message: "response does not start with RESP_HEADER".to_owned(),
9551                };
9552            }
9553        }
9554
9555        let mut failure: Option<(String, String)> = None;
9556        while let Some(frame) = frames.next() {
9557            match frame {
9558                frame @ Frame::PushResult { .. } => {
9559                    let successful = matches!(
9560                        &frame,
9561                        Frame::PushResult {
9562                            status: PushStatus::Applied | PushStatus::Cached,
9563                            ..
9564                        }
9565                    );
9566                    let mut results = vec![frame];
9567                    if successful {
9568                        while matches!(
9569                            frames.peek(),
9570                            Some(Frame::PushResult {
9571                                status: PushStatus::Applied | PushStatus::Cached,
9572                                ..
9573                            })
9574                        ) {
9575                            if let Some(next) = frames.next() {
9576                                results.push(next);
9577                            }
9578                        }
9579                    }
9580                    if self
9581                        .handle_push_results(
9582                            &results,
9583                            &pushed_ids,
9584                            &rejection_details_by_commit,
9585                            &mut report,
9586                            last_final_push_result_id.as_deref(),
9587                        )
9588                        .is_err()
9589                    {
9590                        failure = Some((
9591                            "client.outcome_persistence_failed".to_owned(),
9592                            "local commit outcome could not be persisted".to_owned(),
9593                        ));
9594                        break;
9595                    }
9596                }
9597                Frame::PushResultDetails { .. } => {}
9598                Frame::SubStart {
9599                    id,
9600                    status,
9601                    reason_code,
9602                    effective_scopes,
9603                    bootstrap: _,
9604                } => {
9605                    let mut body = Vec::new();
9606                    let mut sub_end: Option<(i64, Option<String>)> = None;
9607                    for inner in frames.by_ref() {
9608                        match inner {
9609                            Frame::SubEnd {
9610                                next_cursor,
9611                                bootstrap_state,
9612                            } => {
9613                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
9614                                break;
9615                            }
9616                            Frame::Unknown { .. } => {}
9617                            other => body.push(other),
9618                        }
9619                    }
9620                    if let Err(SectionError::Abort(code, message)) = self.process_section(
9621                        transport,
9622                        &id,
9623                        status,
9624                        &reason_code,
9625                        effective_scopes,
9626                        body,
9627                        sub_end,
9628                        meta,
9629                        &mut report,
9630                    ) {
9631                        failure = Some((code, message));
9632                        break;
9633                    }
9634                }
9635                Frame::Lease {
9636                    lease_id,
9637                    expires_at_ms,
9638                } => {
9639                    // §7.3.5: persist the opaque lease; a fresh lease clears
9640                    // any prior lease error (the outage/revocation is over).
9641                    self.set_lease_state(Some(LeaseState {
9642                        lease_id: Some(lease_id),
9643                        expires_at_ms: Some(expires_at_ms),
9644                        error_code: None,
9645                    }));
9646                }
9647                Frame::Error { code, message, .. } => {
9648                    // §1.6: the whole request failed; already-completed
9649                    // subscriptions keep their applied data and cursors.
9650                    failure = Some((code, message));
9651                    break;
9652                }
9653                Frame::Unknown { .. } => {}
9654                _ => {
9655                    failure = Some((
9656                        "sync.invalid_request".to_owned(),
9657                        "unexpected frame in response".to_owned(),
9658                    ));
9659                    break;
9660                }
9661            }
9662        }
9663
9664        // §7.1: reconcile the visible overlay once at the response boundary
9665        // after all outbox acknowledgements and server data applied so a
9666        // batch of PUSH_RESULT frames cannot trigger repeated full-table
9667        // rebuilds. This also covers a round that aborted mid-way.
9668        if self.overlay_dirty.get() {
9669            self.rebuild_overlay();
9670        }
9671        if let Some((error_code, message)) = failure {
9672            return SyncOutcome::Failed {
9673                details: None,
9674                error_code,
9675                message,
9676            };
9677        }
9678        // §4.8 E1: the push half may have drained commits that pinned rows of
9679        // a shrunk window unit — retry any deferred evictions now.
9680        if let Err(message) = self.drain_pending_evictions() {
9681            return SyncOutcome::Failed {
9682                details: None,
9683                error_code: "storage.failed".into(),
9684                message,
9685            };
9686        }
9687        self.ack_after_pull(transport);
9688        // §7.4.5: the reset is over once the first post-reset pull round
9689        // leaves no subscription mid-bootstrap — the tables are rebuilt.
9690        if self.upgrading && report.bootstrapping.is_empty() {
9691            self.set_upgrading(false);
9692        }
9693        SyncOutcome::Ok(report)
9694    }
9695
9696    // -- push results (§6.3, §7.2) ------------------------------------------------
9697
9698    fn handle_push_results(
9699        &mut self,
9700        frames: &[Frame],
9701        pushed_ids: &HashSet<&str>,
9702        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
9703        report: &mut SyncReport,
9704        last_final_push_result_id: Option<&str>,
9705    ) -> Result<(), String> {
9706        self.begin_observation("syncular_push_results")?;
9707        let conflict_count = self.conflicts.len();
9708        let rejection_count = self.rejections.len();
9709        let applied_count = report.applied.len();
9710        let rejected_count = report.rejected.len();
9711        let retryable_count = report.retryable.len();
9712        let was_dirty = self.overlay_dirty.get();
9713        let mut removed = Vec::new();
9714        let mut batch = ChangeAccumulator::default();
9715        let persisted = (|| {
9716            for frame in frames {
9717                let Frame::PushResult {
9718                    client_commit_id,
9719                    status,
9720                    results,
9721                    ..
9722                } = frame
9723                else {
9724                    return Err("unexpected acknowledgement frame".to_owned());
9725                };
9726                if !pushed_ids.contains(client_commit_id.as_str()) {
9727                    continue;
9728                }
9729                let Some(index) = self
9730                    .outbox
9731                    .iter()
9732                    .position(|commit| commit.client_commit_id == *client_commit_id)
9733                else {
9734                    continue;
9735                };
9736                let status = *status;
9737                let rejection_details = rejection_details_by_commit.get(client_commit_id);
9738                let operations = self.outbox[index].ops.clone();
9739                match status {
9740                    PushStatus::Applied | PushStatus::Cached => {
9741                        // §7.2: a lost ack replays as `cached` — proceed as if the
9742                        // ack had arrived.
9743                        let journal_results = results
9744                            .iter()
9745                            .map(|result| {
9746                                let op_index = match result {
9747                                    OpResult::Applied { op_index }
9748                                    | OpResult::Conflict { op_index, .. }
9749                                    | OpResult::Error { op_index, .. } => *op_index,
9750                                };
9751                                CommitOperationOutcome::Applied { op_index }
9752                            })
9753                            .collect::<Vec<_>>();
9754                        let outcome_status = if status == PushStatus::Applied {
9755                            CommitOutcomeStatus::Applied
9756                        } else {
9757                            CommitOutcomeStatus::Cached
9758                        };
9759                        let persisted = self
9760                            .persist_commit_outcome(
9761                                client_commit_id,
9762                                outcome_status,
9763                                &journal_results,
9764                                None,
9765                            )
9766                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9767                        persisted?;
9768                        self.overlay_dirty.set(true);
9769                        batch.status = true;
9770                        batch.outcomes = true;
9771                    }
9772                    PushStatus::Rejected => {
9773                        if results.iter().any(|result| {
9774                            matches!(
9775                                result,
9776                                OpResult::Error {
9777                                    code,
9778                                    retryable: true,
9779                                    ..
9780                                } if code == "sync.idempotency_cache_miss"
9781                            )
9782                        }) {
9783                            // §6.3/§7.2: a serving failure, not an outcome — keep the
9784                            // exact commit queued for an identical retry.
9785                            report.retryable.push(client_commit_id.to_owned());
9786                            continue;
9787                        }
9788
9789                        let mut journal_results = Vec::with_capacity(results.len());
9790                        let mut conflicts = Vec::new();
9791                        let mut rejections = Vec::new();
9792                        for result in results {
9793                            match result {
9794                                OpResult::Applied { op_index } => {
9795                                    journal_results.push(CommitOperationOutcome::Applied {
9796                                        op_index: *op_index,
9797                                    });
9798                                }
9799                                OpResult::Conflict {
9800                                    op_index,
9801                                    code,
9802                                    message,
9803                                    server_version,
9804                                    server_row,
9805                                    conflict_columns: conflict_columns_bitmap,
9806                                } => {
9807                                    let operation = operations
9808                                        .get(*op_index as usize)
9809                                        .map(CommitOperation::from);
9810                                    let (table, row_id) = operation
9811                                        .as_ref()
9812                                        .map(|op| (op.table.clone(), op.row_id.clone()))
9813                                        .unwrap_or_default();
9814                                    let server_row_json = self
9815                                        .schema
9816                                        .table(&table)
9817                                        .and_then(|t| {
9818                                            decode_row_bytes(t, server_row, &self.encryption)
9819                                                .ok()
9820                                                .map(|row| (t, row))
9821                                        })
9822                                        .map(|(t, row)| {
9823                                            let mut map = Map::new();
9824                                            for (i, column) in t.columns.iter().enumerate() {
9825                                                map.insert(
9826                                                    column.name.clone(),
9827                                                    column_value_to_json(
9828                                                        row.get(i).unwrap_or(&None),
9829                                                    ),
9830                                                );
9831                                            }
9832                                            map
9833                                        })
9834                                        .unwrap_or_default();
9835                                    // §6.3: decode the presence-layout bitmap
9836                                    // into the marked column names.
9837                                    let conflict_columns = self
9838                                        .schema
9839                                        .table(&table)
9840                                        .map(|t| {
9841                                            t.columns
9842                                                .iter()
9843                                                .enumerate()
9844                                                .filter(|(index, _)| {
9845                                                    conflict_columns_bitmap
9846                                                        .get(index / 8)
9847                                                        .is_some_and(|byte| {
9848                                                            byte & (1 << (index % 8)) != 0
9849                                                        })
9850                                                })
9851                                                .map(|(_, column)| column.name.clone())
9852                                                .collect::<Vec<_>>()
9853                                        })
9854                                        .unwrap_or_default();
9855                                    let conflict = ConflictRecord {
9856                                        client_commit_id: client_commit_id.to_owned(),
9857                                        op_index: *op_index,
9858                                        table,
9859                                        row_id,
9860                                        code: code.clone(),
9861                                        message: message.clone(),
9862                                        server_version: *server_version,
9863                                        server_row: server_row_json,
9864                                        conflict_columns,
9865                                        operation,
9866                                    };
9867                                    journal_results.push(CommitOperationOutcome::Conflict {
9868                                        conflict: conflict.clone(),
9869                                    });
9870                                    conflicts.push(conflict);
9871                                }
9872                                OpResult::Error {
9873                                    op_index,
9874                                    code,
9875                                    message,
9876                                    retryable,
9877                                } => {
9878                                    let rejection = RejectionRecord {
9879                                        client_commit_id: client_commit_id.to_owned(),
9880                                        op_index: *op_index,
9881                                        code: code.clone(),
9882                                        message: message.clone(),
9883                                        retryable: *retryable,
9884                                        details: rejection_details
9885                                            .and_then(|details| details.get(op_index))
9886                                            .cloned(),
9887                                        operation: operations
9888                                            .get(*op_index as usize)
9889                                            .map(CommitOperation::from),
9890                                    };
9891                                    journal_results.push(CommitOperationOutcome::Error {
9892                                        rejection: rejection.clone(),
9893                                    });
9894                                    rejections.push(rejection);
9895                                }
9896                            }
9897                        }
9898                        let outcome_status = if conflicts.is_empty() {
9899                            CommitOutcomeStatus::Rejected
9900                        } else {
9901                            CommitOutcomeStatus::Conflict
9902                        };
9903                        let persisted = self
9904                            .persist_commit_outcome(
9905                                client_commit_id,
9906                                outcome_status,
9907                                &journal_results,
9908                                Some(&operations),
9909                            )
9910                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9911                        persisted?;
9912                        batch.conflicts = !conflicts.is_empty();
9913                        batch.rejections = !rejections.is_empty();
9914                        batch.status = true;
9915                        batch.outcomes = true;
9916                        self.conflicts.extend(conflicts);
9917                        self.rejections.extend(rejections);
9918                        self.overlay_dirty.set(true);
9919                    }
9920                }
9921                removed.push((index, self.outbox.remove(index)));
9922                if batch.status {
9923                    for operation in &operations {
9924                        if !self.record_row_scopes(
9925                            &mut batch,
9926                            &operation.table,
9927                            &operation.row_id,
9928                            false,
9929                        ) {
9930                            batch.table(&operation.table);
9931                        }
9932                    }
9933                }
9934                match status {
9935                    PushStatus::Applied | PushStatus::Cached => {
9936                        report.applied.push(client_commit_id.to_owned())
9937                    }
9938                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
9939                }
9940            }
9941            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)) {
9942                self.prune_commit_outcomes()?;
9943            }
9944            self.finish_observation("syncular_push_results", batch)
9945        })();
9946        if let Err(error) = persisted {
9947            self.rollback_observation("syncular_push_results");
9948            for (index, commit) in removed.into_iter().rev() {
9949                self.outbox.insert(index, commit);
9950            }
9951            self.conflicts.truncate(conflict_count);
9952            self.rejections.truncate(rejection_count);
9953            self.overlay_dirty.set(was_dirty);
9954            report.applied.truncate(applied_count);
9955            report.rejected.truncate(rejected_count);
9956            report.retryable.truncate(retryable_count);
9957            return Err(error);
9958        }
9959        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
9960        Ok(())
9961    }
9962
9963    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
9964        status != PushStatus::Rejected
9965            || !results.iter().any(|result| {
9966                matches!(
9967                    result,
9968                    OpResult::Error {
9969                        code,
9970                        retryable: true,
9971                        ..
9972                    } if code == "sync.idempotency_cache_miss"
9973                )
9974            })
9975    }
9976
9977    // -- subscription sections ------------------------------------------------------
9978
9979    #[allow(clippy::too_many_arguments)]
9980    fn process_section(
9981        &mut self,
9982        transport: &mut dyn Transport,
9983        id: &str,
9984        status: SubStatus,
9985        reason_code: &str,
9986        effective_scopes: Vec<(String, Vec<String>)>,
9987        body: Vec<Frame>,
9988        sub_end: Option<(i64, Option<String>)>,
9989        meta: &RequestMeta,
9990        report: &mut SyncReport,
9991    ) -> Result<(), SectionError> {
9992        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9993            return Ok(()); // unknown echo: ignore
9994        };
9995        if status != SubStatus::Active && sub_end.is_none() {
9996            return Err(SectionError::Abort(
9997                "sync.invalid_request".into(),
9998                "subscription section without SUB_END".into(),
9999            ));
10000        }
10001        match status {
10002            SubStatus::Revoked => {
10003                self.begin_observation("syncular_revocation")
10004                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10005                let previous = self.subs[sub_index].clone();
10006                let was_dirty = self.overlay_dirty.get();
10007                let previous_outbox = self.outbox.clone();
10008                let rejection_count = self.rejections.len();
10009                let revoked_count = report.revoked.len();
10010                let failed_count = report.failed.len();
10011                let result =
10012                    (|| {
10013                        let mut batch = ChangeAccumulator::default();
10014                        let registered = self.window_unit_by_sub_id(id);
10015                        // §3.3: stop pulling, purge exactly the last effective grant.
10016                        let (table, effective) = {
10017                            let sub = &self.subs[sub_index];
10018                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10019                        };
10020                        let purged = self.purge_scope_rows(&table, &effective);
10021                        match purged {
10022                            Ok(()) => {
10023                                self.record_scope_map(&mut batch, &table, &effective);
10024                                let sub = &mut self.subs[sub_index];
10025                                sub.state = SubState::Revoked;
10026                                sub.reason_code = Some(if reason_code.is_empty() {
10027                                    "sync.scope_revoked".to_owned()
10028                                } else {
10029                                    reason_code.to_owned()
10030                                });
10031                                report.revoked.push(id.to_owned());
10032                                let doomed_effective = effective;
10033                                let sub_table = table;
10034                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
10035                                    |message| SectionError::Abort("storage.failed".into(), message),
10036                                )?;
10037                                let dropped = self
10038                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
10039                                    .map_err(|message| {
10040                                        SectionError::Abort("storage.failed".to_owned(), message)
10041                                    })?;
10042                                if dropped {
10043                                    batch.status = true;
10044                                    batch.rejections = true;
10045                                    batch.outcomes = true;
10046                                }
10047                                // §5.9.7 B2: revocation deletes now-unauthorized blob
10048                                // bodies (evicted ≠ revoked).
10049                                self.delete_unreferenced_cached_blobs();
10050                            }
10051                            Err(()) => {
10052                                // §3.3 fail closed: no local mapping — never clear by
10053                                // approximation; fatal configuration error.
10054                                let sub = &mut self.subs[sub_index];
10055                                sub.state = SubState::Failed;
10056                                sub.reason_code = Some("sync.scope_revoked".to_owned());
10057                                report.failed.push(id.to_owned());
10058                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
10059                                    |message| SectionError::Abort("storage.failed".into(), message),
10060                                )?;
10061                            }
10062                        }
10063                        if let Some((base_key, unit)) = registered {
10064                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
10065                        }
10066                        self.rebuild_overlay_if_dirty();
10067                        self.finish_observation("syncular_revocation", batch)
10068                            .map_err(|message| {
10069                                SectionError::Abort("storage.failed".to_owned(), message)
10070                            })?;
10071                        Ok(())
10072                    })();
10073                if result.is_err() {
10074                    self.rollback_observation("syncular_revocation");
10075                    self.subs[sub_index] = previous;
10076                    self.overlay_dirty.set(was_dirty);
10077                    self.outbox = previous_outbox;
10078                    self.rejections.truncate(rejection_count);
10079                    report.revoked.truncate(revoked_count);
10080                    report.failed.truncate(failed_count);
10081                }
10082                result
10083            }
10084            SubStatus::Reset => {
10085                self.begin_observation("syncular_reset")
10086                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10087                let previous = self.subs[sub_index].clone();
10088                let was_dirty = self.overlay_dirty.get();
10089                let reset_count = report.resets.len();
10090                let result = (|| {
10091                    let mut batch = ChangeAccumulator::default();
10092                    let registered = self.window_unit_by_sub_id(id);
10093                    // §4.6: discard cursor + bootstrap state, keep local rows —
10094                    // reset is a staleness signal, not a purge signal.
10095                    let sub = &mut self.subs[sub_index];
10096                    sub.cursor = -1;
10097                    sub.bootstrap_state = None;
10098                    report.resets.push(id.to_owned());
10099                    self.persist_sub(&self.subs[sub_index].clone())
10100                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10101                    if let Some((base_key, unit)) = registered {
10102                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
10103                    }
10104                    self.finish_observation("syncular_reset", batch)
10105                        .map_err(|message| {
10106                            SectionError::Abort("storage.failed".to_owned(), message)
10107                        })?;
10108                    Ok(())
10109                })();
10110                if result.is_err() {
10111                    self.rollback_observation("syncular_reset");
10112                    self.subs[sub_index] = previous;
10113                    self.overlay_dirty.set(was_dirty);
10114                    report.resets.truncate(reset_count);
10115                }
10116                result
10117            }
10118            SubStatus::Active => {
10119                let fresh = meta
10120                    .fresh
10121                    .iter()
10122                    .find(|(fid, _)| fid == id)
10123                    .map(|(_, fresh)| *fresh)
10124                    .unwrap_or(false);
10125                let previous = self.subs[sub_index].clone();
10126                self.subs[sub_index].effective = Some(effective_scopes);
10127                let outcome = self
10128                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
10129                    .and_then(|()| {
10130                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
10131                            SectionError::Abort(
10132                                "sync.invalid_request".into(),
10133                                "subscription section without SUB_END".into(),
10134                            )
10135                        })?;
10136                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
10137                            .map_err(|message| {
10138                                SectionError::Abort("storage.failed".into(), message)
10139                            })
10140                    });
10141                match outcome {
10142                    Ok(()) => {
10143                        if self.subs[sub_index].bootstrap_state.is_some() {
10144                            report.bootstrapping.push(id.to_owned());
10145                        }
10146                        Ok(())
10147                    }
10148                    Err(SectionError::FailClosed) => {
10149                        self.subs[sub_index] = previous.clone();
10150                        self.begin_observation("syncular_section_failure")
10151                            .map_err(|message| {
10152                                SectionError::Abort("storage.failed".into(), message)
10153                            })?;
10154                        let persisted = (|| {
10155                            let mut batch = ChangeAccumulator::default();
10156                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
10157                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
10158                            }
10159                            self.subs[sub_index].state = SubState::Failed;
10160                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
10161                            self.persist_sub(&self.subs[sub_index])?;
10162                            self.finish_observation("syncular_section_failure", batch)
10163                        })();
10164                        if let Err(message) = persisted {
10165                            self.rollback_observation("syncular_section_failure");
10166                            self.subs[sub_index] = previous;
10167                            return Err(SectionError::Abort("storage.failed".into(), message));
10168                        }
10169                        report.failed.push(id.to_owned());
10170                        Ok(())
10171                    }
10172                    Err(error) => {
10173                        // Frame/block transactions already completed. Preserve their
10174                        // rows and revisions, but leave this subscription cursor unchanged.
10175                        self.subs[sub_index] = previous;
10176                        Err(error)
10177                    }
10178                }
10179            }
10180        }
10181    }
10182
10183    fn apply_sub_end(
10184        &mut self,
10185        sub_index: usize,
10186        cursor: i64,
10187        bootstrap_state: Option<String>,
10188    ) -> Result<(), String> {
10189        #[cfg(feature = "bench-internals")]
10190        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
10191        let previous = self.subs[sub_index].clone();
10192        self.begin_observation("syncular_sub_end")?;
10193        let persisted = (|| {
10194            let mut batch = ChangeAccumulator::default();
10195            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
10196                && cursor >= 0
10197                && bootstrap_state.is_none();
10198            if completed {
10199                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
10200                    batch.window(&base_key, &previous.table, &unit);
10201                }
10202            }
10203            let sub = &mut self.subs[sub_index];
10204            sub.cursor = cursor;
10205            sub.bootstrap_state = bootstrap_state;
10206            sub.synced_once = true;
10207            self.persist_sub(&self.subs[sub_index])?;
10208            self.finish_observation("syncular_sub_end", batch)
10209        })();
10210        if persisted.is_err() {
10211            self.rollback_observation("syncular_sub_end");
10212            self.subs[sub_index] = previous;
10213        }
10214        persisted
10215    }
10216
10217    fn apply_section_body(
10218        &mut self,
10219        transport: &mut dyn Transport,
10220        sub_index: usize,
10221        body: Vec<Frame>,
10222        fresh: bool,
10223        meta: &RequestMeta,
10224        report: &mut SyncReport,
10225    ) -> Result<(), SectionError> {
10226        let mut saw_segment = false;
10227        for frame in body {
10228            match frame {
10229                Frame::Commit {
10230                    tables, changes, ..
10231                } => {
10232                    self.apply_commit_frame(&tables, &changes)
10233                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
10234                    report.commits_applied += 1;
10235                }
10236                Frame::SegmentInline { payload } => {
10237                    let segment = decode_rows_segment(&payload)
10238                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
10239                    self.progress.update(|p| {
10240                        p.phase = ProgressPhase::Import;
10241                        p.subscription_id = Some(self.subs[sub_index].id.clone());
10242                        p.table = Some(self.subs[sub_index].table.clone());
10243                        p.segment_id = None;
10244                        p.bytes_received = payload.len() as u64;
10245                        p.bytes_total = Some(payload.len() as u64);
10246                        p.rows_processed = 0;
10247                        p.rows_total =
10248                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
10249                    });
10250                    let first = !saw_segment;
10251                    saw_segment = true;
10252                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10253                    report.segment_rows_applied += applied;
10254                }
10255                Frame::SegmentRef {
10256                    segment_id,
10257                    byte_length,
10258                    media_type,
10259                    table,
10260                    row_count,
10261                    as_of_commit_seq,
10262                    scope_digest,
10263                    row_cursor,
10264                    next_row_cursor,
10265                    url,
10266                    url_expires_at_ms,
10267                    ..
10268                } => {
10269                    // §4.2: reject a descriptor whose mediaType was not
10270                    // advertised — never skip or guess.
10271                    let advertised = match media_type {
10272                        MediaType::Rows => {
10273                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
10274                                || meta.accept & ACCEPT_INLINE_ROWS != 0
10275                        }
10276                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
10277                    };
10278                    if !advertised {
10279                        return Err(SectionError::Abort(
10280                            "sync.invalid_request".to_owned(),
10281                            format!(
10282                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
10283                                media_type.name()
10284                            ),
10285                        ));
10286                    }
10287                    self.progress.update(|p| {
10288                        p.phase = ProgressPhase::Download;
10289                        p.subscription_id = Some(self.subs[sub_index].id.clone());
10290                        p.table = Some(table.clone());
10291                        p.segment_id = Some(segment_id.clone());
10292                        p.bytes_received = 0;
10293                        p.bytes_total = u64::try_from(byte_length).ok();
10294                        p.rows_processed = 0;
10295                        p.rows_total = u64::try_from(row_count).ok();
10296                    });
10297                    let observer = self.progress.clone();
10298                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
10299                    let bytes = if let Some(url) = url {
10300                        // §5.4: a url-carrying descriptor MUST be fetched
10301                        // from that URL; failure invalidates the whole
10302                        // descriptor (no fall-through to §5.5 — re-pull
10303                        // recovers, §1.4 rule 5).
10304                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
10305                            return Err(SectionError::Abort(
10306                                "sync.invalid_request".to_owned(),
10307                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
10308                                    .to_owned(),
10309                            ));
10310                        }
10311                        // §5.4: MUST NOT start a fetch at/past expiry.
10312                        if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10313                            return Err(SectionError::Abort(
10314                                "sync.segment_expired".to_owned(),
10315                                format!(
10316                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
10317                                ),
10318                            ));
10319                        }
10320                        transport
10321                            .fetch_url(&url, &mut on_progress)
10322                            .map_err(|e| SectionError::Abort(e.code, e.message))?
10323                    } else {
10324                        let requested_scopes_json =
10325                            canonical_scope_json(&self.subs[sub_index].requested);
10326                        transport
10327                            .download_segment(
10328                                &SegmentRequest {
10329                                    segment_id: segment_id.clone(),
10330                                    table,
10331                                    requested_scopes_json,
10332                                },
10333                                &mut on_progress,
10334                            )
10335                            .map_err(|e| SectionError::Abort(e.code, e.message))?
10336                    };
10337                    // §5.1: verify the content address before applying.
10338                    on_progress(bytes.len() as u64);
10339                    let digest = Sha256::digest(&bytes);
10340                    let expected = segment_id
10341                        .strip_prefix("sha256:")
10342                        .unwrap_or(segment_id.as_str());
10343                    if bytes_to_hex(&digest) != expected {
10344                        // §5.1: the client discards the segment and re-pulls.
10345                        self.schedule_background_retry();
10346                        return Err(SectionError::Abort(
10347                            "sync.invalid_request".to_owned(),
10348                            "segment bytes do not match the content address (§5.1)".to_owned(),
10349                        ));
10350                    }
10351                    self.progress.update(|p| p.phase = ProgressPhase::Import);
10352                    if media_type == MediaType::Sqlite {
10353                        // §5.3: images are whole-table — a paged sqlite
10354                        // descriptor is invalid.
10355                        if row_cursor.is_some() || next_row_cursor.is_some() {
10356                            return Err(SectionError::Abort(
10357                                "sync.invalid_request".to_owned(),
10358                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
10359                                    .to_owned(),
10360                            ));
10361                        }
10362                        let first = !saw_segment;
10363                        saw_segment = true;
10364                        let applied = self.apply_sqlite_segment(
10365                            sub_index,
10366                            &bytes,
10367                            fresh && first,
10368                            row_count,
10369                            as_of_commit_seq,
10370                            &scope_digest,
10371                        )?;
10372                        report.segment_rows_applied += applied;
10373                    } else {
10374                        let segment = decode_rows_segment(&bytes).map_err(|e| {
10375                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
10376                        })?;
10377                        let first = row_cursor.is_none();
10378                        saw_segment = true;
10379                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10380                        report.segment_rows_applied += applied;
10381                    }
10382                }
10383                Frame::Unknown { .. } => {}
10384                Frame::Error { code, message, .. } => {
10385                    return Err(SectionError::Abort(code, message))
10386                }
10387                _ => {
10388                    return Err(SectionError::Abort(
10389                        "sync.invalid_request".to_owned(),
10390                        "unexpected frame inside a subscription section".to_owned(),
10391                    ));
10392                }
10393            }
10394        }
10395        Ok(())
10396    }
10397
10398    // Both pull and realtime frames use the same durable observation boundary.
10399    fn apply_commit_frame(
10400        &mut self,
10401        tables: &[String],
10402        changes: &[ssp2::model::Change],
10403    ) -> Result<(), (String, String)> {
10404        #[cfg(feature = "bench-internals")]
10405        let _phase = self.benchmark_phases.start(Phase::CommitApply);
10406        let was_dirty = self.overlay_dirty.get();
10407        self.begin_observation("syncular_commit")
10408            .map_err(|message| ("storage.failed".into(), message))?;
10409        let applied = (|| {
10410            let mut batch = ChangeAccumulator::default();
10411            self.record_commit_changes(&mut batch, tables, changes);
10412            self.apply_commit_changes(tables, changes)?;
10413            self.rebuild_overlay_if_dirty();
10414            self.finish_observation("syncular_commit", batch)
10415                .map_err(|message| ("storage.failed".into(), message))
10416        })();
10417        if applied.is_err() {
10418            self.rollback_observation("syncular_commit");
10419            self.overlay_dirty.set(was_dirty);
10420        }
10421        applied
10422    }
10423
10424    fn apply_commit_changes(
10425        &mut self,
10426        tables: &[String],
10427        changes: &[ssp2::model::Change],
10428    ) -> Result<(), (String, String)> {
10429        // A clean overlay can reconcile changed primary keys independently
10430        // when their tables have no secondary unique constraints. Such a
10431        // constraint can change whether another row's pending write succeeds,
10432        // so those frames still require the complete FIFO replay below.
10433        let mirror_visible = !self.overlay_dirty.get()
10434            && (self.outbox.is_empty()
10435                || changes.iter().all(|change| {
10436                    tables
10437                        .get(change.table_index as usize)
10438                        .and_then(|name| self.schema.table(name))
10439                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
10440                }));
10441        for change in changes {
10442            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
10443                (
10444                    "sync.invalid_request".to_owned(),
10445                    "change tableIndex out of range".to_owned(),
10446                )
10447            })?;
10448            let table = self.schema.table(table_name).ok_or_else(|| {
10449                (
10450                    "sync.schema_mismatch".to_owned(),
10451                    format!("change targets unknown table {table_name:?}"),
10452                )
10453            })?;
10454            match change.op {
10455                Op::Upsert => {
10456                    let payload = change.row.as_ref().ok_or_else(|| {
10457                        (
10458                            "sync.invalid_request".to_owned(),
10459                            "upsert change without row payload".to_owned(),
10460                        )
10461                    })?;
10462                    // §5.11: decrypt encrypted columns on apply.
10463                    let row = {
10464                        #[cfg(feature = "bench-internals")]
10465                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
10466                        decode_row_bytes(table, payload, &self.encryption)
10467                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
10468                    };
10469                    let version = change.row_version.unwrap_or(0);
10470                    let table_name = table.name.clone();
10471                    self.write_base_row(&table_name, &row, version)
10472                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10473                    if mirror_visible {
10474                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
10475                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10476                    }
10477                }
10478                Op::Delete => {
10479                    self.delete_base_row(table_name, &change.row_id)
10480                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10481                    if mirror_visible {
10482                        let sql = format!(
10483                            "DELETE FROM {} WHERE {}",
10484                            visible_table(table_name),
10485                            row_id_predicate(table)
10486                        );
10487                        self.conn
10488                            .prepare_cached(&sql)
10489                            .and_then(|mut statement| {
10490                                statement.execute(rusqlite::params![change.row_id])
10491                            })
10492                            .map_err(|error| {
10493                                ("sync.invalid_request".to_owned(), error.to_string())
10494                            })?;
10495                    }
10496                }
10497            }
10498        }
10499        if mirror_visible {
10500            if !self.outbox.is_empty() {
10501                let changed_rows: HashSet<(&str, &str)> = changes
10502                    .iter()
10503                    .map(|change| {
10504                        (
10505                            tables[change.table_index as usize].as_str(),
10506                            change.row_id.as_str(),
10507                        )
10508                    })
10509                    .collect();
10510                self.apply_outbox_ops(
10511                    self.outbox
10512                        .iter()
10513                        .flat_map(|commit| &commit.ops)
10514                        .filter(|op| {
10515                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
10516                        }),
10517                );
10518            }
10519            self.overlay_dirty.set(false);
10520        }
10521        Ok(())
10522    }
10523
10524    /// §5.6 segment application: validate against the generated schema,
10525    /// clear the grant on a fresh bootstrap's first page (fail closed
10526    /// without a mapping), then replace-or-upsert each row with its
10527    /// segment-carried server version (§5.2).
10528    fn apply_segment(
10529        &mut self,
10530        sub_index: usize,
10531        segment: &RowsSegment,
10532        first_fresh_page: bool,
10533    ) -> Result<u32, SectionError> {
10534        let (sub_table, effective) = {
10535            let sub = &self.subs[sub_index];
10536            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10537        };
10538        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10539            SectionError::Abort(
10540                "sync.schema_mismatch".to_owned(),
10541                format!("subscription table {sub_table:?} is not in the client schema"),
10542            )
10543        })?;
10544        // §5.2: the column table validates against the generated schema —
10545        // order, names, types, nullability; mismatch is fatal. §5.11: the
10546        // server sends the WIRE types (bytes for an encrypted column), so
10547        // validate against wire_columns.
10548        let matches = segment.table == table.name
10549            && segment.schema_version == self.schema.version
10550            && segment.columns.len() == table.wire_columns.len()
10551            && segment
10552                .columns
10553                .iter()
10554                .zip(table.wire_columns.iter())
10555                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
10556        if !matches {
10557            return Err(SectionError::Abort(
10558                "sync.schema_mismatch".to_owned(),
10559                "segment column table does not match the generated schema (§5.2)".to_owned(),
10560            ));
10561        }
10562        let mut applied = 0u32;
10563        let empty = Vec::new();
10564        let blocks = if segment.blocks.is_empty() {
10565            std::slice::from_ref(&empty)
10566        } else {
10567            &segment.blocks
10568        };
10569        for (index, block) in blocks.iter().enumerate() {
10570            let clear = first_fresh_page && index == 0;
10571            if !clear && block.is_empty() {
10572                continue;
10573            }
10574            let was_dirty = self.overlay_dirty.get();
10575            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10576                && self
10577                    .outbox
10578                    .iter()
10579                    .flat_map(|commit| &commit.ops)
10580                    .any(|op| op.table == table.name);
10581            let mirror_visible = !reconcile_pending;
10582            self.begin_observation("syncular_segment_block")
10583                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10584            let outcome = (|| {
10585                self.rebuild_overlay_if_dirty();
10586                let mut changed_keys = Vec::new();
10587                let mut batch = ChangeAccumulator::default();
10588                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
10589                    batch.table(&table.name);
10590                }
10591                if clear {
10592                    self.purge_scope_rows(&table.name, &effective)
10593                        .map_err(|()| SectionError::FailClosed)?;
10594                }
10595                for (row_index, row) in block.iter().enumerate() {
10596                    let decrypted;
10597                    let values = if table.has_encrypted_columns() {
10598                        let mut values = row.values.clone();
10599                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
10600                            .map_err(|message| {
10601                            SectionError::Abort("client.decrypt_failed".into(), message)
10602                        })?;
10603                        decrypted = values;
10604                        &decrypted
10605                    } else {
10606                        &row.values
10607                    };
10608                    self.write_base_row(&table.name, values, row.server_version)
10609                        .map_err(|message| {
10610                            SectionError::Abort("sync.invalid_request".into(), message)
10611                        })?;
10612                    if reconcile_pending {
10613                        changed_keys.push(
10614                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
10615                                |message| SectionError::Abort("storage.failed".into(), message),
10616                            )?,
10617                        );
10618                    }
10619                    if mirror_visible {
10620                        self.write_row(
10621                            &visible_table(&table.name),
10622                            &table.name,
10623                            values,
10624                            row.server_version,
10625                        )
10626                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10627                    }
10628                    let processed = u64::from(applied) + row_index as u64 + 1;
10629                    if processed.is_multiple_of(1024) {
10630                        self.progress.update(|p| p.rows_processed = processed);
10631                    }
10632                }
10633                if reconcile_pending {
10634                    self.reconcile_imported_rows(&table, &mut changed_keys)
10635                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10636                }
10637                self.apply_outbox_ops(
10638                    self.outbox
10639                        .iter()
10640                        .flat_map(|commit| &commit.ops)
10641                        .filter(|op| op.table == table.name),
10642                );
10643                self.overlay_dirty.set(false);
10644                self.finish_observation("syncular_segment_block", batch)
10645                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
10646            })();
10647            if let Err(error) = outcome {
10648                self.rollback_observation("syncular_segment_block");
10649                self.overlay_dirty.set(was_dirty);
10650                return Err(error);
10651            }
10652            applied += block.len() as u32;
10653            self.progress
10654                .update(|p| p.rows_processed = u64::from(applied));
10655            std::thread::yield_now();
10656        }
10657        self.progress
10658            .update(|p| p.rows_processed = u64::from(applied));
10659        Ok(applied)
10660    }
10661
10662    /// §5.3 sqlite-image application: validate the in-file metadata
10663    /// against the descriptor, validate column names/order against the
10664    /// generated schema, run the §5.6 first-page clear when fresh, then
10665    /// replace-or-upsert every image row with its `_syncular_version`.
10666    /// Mechanics: the image lands in a temp file read through a second
10667    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
10668    /// Each image chunk commits its writes and revision before readers continue.
10669    fn apply_sqlite_segment(
10670        &mut self,
10671        sub_index: usize,
10672        bytes: &[u8],
10673        first_fresh_page: bool,
10674        row_count: i64,
10675        as_of_commit_seq: i64,
10676        scope_digest: &str,
10677    ) -> Result<u32, SectionError> {
10678        let invalid = |detail: &str| {
10679            SectionError::Abort(
10680                "sync.invalid_request".to_owned(),
10681                format!("sqlite segment rejected: {detail} (§5.3)"),
10682            )
10683        };
10684        let (sub_table, effective) = {
10685            let sub = &self.subs[sub_index];
10686            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10687        };
10688        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10689            SectionError::Abort(
10690                "sync.schema_mismatch".to_owned(),
10691                format!("subscription table {sub_table:?} is not in the client schema"),
10692            )
10693        })?;
10694
10695        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
10696        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
10697        let img = match rusqlite::Connection::open_with_flags(
10698            &path,
10699            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
10700        ) {
10701            Ok(conn) => conn,
10702            Err(_) => {
10703                let _ = std::fs::remove_file(&path);
10704                return Err(invalid("bytes do not open as a SQLite database"));
10705            }
10706        };
10707        let outcome = self.apply_sqlite_image(
10708            &img,
10709            &table,
10710            first_fresh_page,
10711            &effective,
10712            row_count,
10713            as_of_commit_seq,
10714            scope_digest,
10715        );
10716        drop(img);
10717        let _ = std::fs::remove_file(&path);
10718        outcome
10719    }
10720
10721    #[allow(clippy::too_many_arguments)]
10722    fn apply_sqlite_image(
10723        &mut self,
10724        img: &rusqlite::Connection,
10725        table: &crate::schema::TableSchema,
10726        first_fresh_page: bool,
10727        effective: &[(String, Vec<String>)],
10728        row_count: i64,
10729        as_of_commit_seq: i64,
10730        scope_digest: &str,
10731    ) -> Result<u32, SectionError> {
10732        let invalid = |detail: String| {
10733            SectionError::Abort(
10734                "sync.invalid_request".to_owned(),
10735                format!("sqlite segment rejected: {detail} (§5.3)"),
10736            )
10737        };
10738
10739        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
10740        //    one row).
10741        type MetaRow = (i64, String, i64, i64, String, i64, i64);
10742        let meta: MetaRow = img
10743            .query_row(
10744                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
10745                        \"scopeDigest\", \"rowCount\",
10746                        (SELECT count(*) FROM _syncular_segment)
10747                 FROM _syncular_segment",
10748                [],
10749                |row| {
10750                    Ok((
10751                        row.get(0)?,
10752                        row.get(1)?,
10753                        row.get(2)?,
10754                        row.get(3)?,
10755                        row.get(4)?,
10756                        row.get(5)?,
10757                        row.get(6)?,
10758                    ))
10759                },
10760            )
10761            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
10762        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
10763        if meta_count != 1 {
10764            return Err(invalid(format!(
10765                "_syncular_segment must contain exactly one row, found {meta_count}"
10766            )));
10767        }
10768        if format != 1 {
10769            return Err(invalid(format!("format {format}")));
10770        }
10771        if meta_table != table.name {
10772            return Err(invalid(format!("image table {meta_table:?}")));
10773        }
10774        if schema_version != i64::from(self.schema.version) {
10775            return Err(invalid(format!("schemaVersion {schema_version}")));
10776        }
10777        if pin != as_of_commit_seq {
10778            return Err(invalid(format!("asOfCommitSeq {pin}")));
10779        }
10780        if digest != scope_digest {
10781            return Err(invalid("scopeDigest mismatch".to_owned()));
10782        }
10783        if meta_rows != row_count {
10784            return Err(invalid(format!("rowCount {meta_rows}")));
10785        }
10786
10787        // 2. Column names and order vs the generated schema (§5.3 rule 3).
10788        let mut names: Vec<String> = Vec::new();
10789        let mut primary_keys: Vec<String> = Vec::new();
10790        {
10791            let mut stmt = img
10792                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
10793                .map_err(|_| invalid("image data table missing".to_owned()))?;
10794            let mut rows = stmt
10795                .query([])
10796                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
10797            while let Some(row) = rows
10798                .next()
10799                .map_err(|_| invalid("image data table unreadable".to_owned()))?
10800            {
10801                if row
10802                    .get::<_, i64>(5)
10803                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
10804                    > 0
10805                {
10806                    primary_keys.push(row.get(1).map_err(|error| {
10807                        invalid(Self::sqlite_failure(&self.storage_failure, error))
10808                    })?);
10809                }
10810                names.push(
10811                    row.get::<_, String>(1)
10812                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
10813                );
10814            }
10815        }
10816        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
10817        expected.push("_syncular_version");
10818        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
10819            return Err(SectionError::Abort(
10820                "sync.schema_mismatch".to_owned(),
10821                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
10822            ));
10823        }
10824
10825        if primary_keys != vec![table.primary_key.clone()] {
10826            return Err(invalid(
10827                "image primary key does not match generated schema".into(),
10828            ));
10829        }
10830        let actual_count: i64 = img
10831            .query_row(
10832                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
10833                [],
10834                |row| row.get(0),
10835            )
10836            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
10837        if actual_count != row_count {
10838            return Err(invalid("image row count does not match descriptor".into()));
10839        }
10840        let insert = self.insert_row_sql(&base_table(&table.name), table);
10841        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
10842        let column_list = names
10843            .iter()
10844            .map(|n| quote_ident(n))
10845            .collect::<Vec<_>>()
10846            .join(", ");
10847        let mut stmt = img
10848            .prepare(&format!(
10849                "SELECT {column_list} FROM {} ORDER BY {}",
10850                quote_ident(&table.name),
10851                quote_ident(&table.primary_key)
10852            ))
10853            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
10854        let mut rows = stmt
10855            .query([])
10856            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
10857        let mut applied = 0u32;
10858        loop {
10859            let clear = applied == 0 && first_fresh_page;
10860            let was_dirty = self.overlay_dirty.get();
10861            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10862                && self
10863                    .outbox
10864                    .iter()
10865                    .flat_map(|commit| &commit.ops)
10866                    .any(|op| op.table == table.name);
10867            let mirror_visible = !reconcile_pending;
10868            self.begin_observation("syncular_image_chunk")
10869                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10870            let result = (|| {
10871                self.rebuild_overlay_if_dirty();
10872                let mut changed_keys = Vec::new();
10873                let mut batch = ChangeAccumulator::default();
10874                if clear {
10875                    if self.scoped_rows_exist(&table.name, effective) {
10876                        batch.table(&table.name);
10877                    }
10878                    self.purge_scope_rows(&table.name, effective)
10879                        .map_err(|()| SectionError::FailClosed)?;
10880                }
10881                let mut processed = 0u32;
10882                {
10883                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
10884                        invalid(Self::sqlite_failure(&self.storage_failure, error))
10885                    })?;
10886                    let mut mirror = if mirror_visible {
10887                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
10888                            invalid(Self::sqlite_failure(&self.storage_failure, error))
10889                        })?)
10890                    } else {
10891                        None
10892                    };
10893                    for _ in 0..1024 {
10894                        let Some(row) = rows.next().map_err(|error| {
10895                            invalid(Self::sqlite_failure(&self.storage_failure, error))
10896                        })?
10897                        else {
10898                            break;
10899                        };
10900                        for (i, column) in table.columns.iter().enumerate() {
10901                            let cell = row.get_ref(i).map_err(|error| {
10902                                invalid(Self::sqlite_failure(&self.storage_failure, error))
10903                            })?;
10904                            let param = image_cell_param(column, cell).map_err(invalid)?;
10905                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
10906                                invalid(Self::sqlite_failure(&self.storage_failure, error))
10907                            })?;
10908                            if let Some(mirror) = &mut mirror {
10909                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
10910                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
10911                                })?;
10912                            }
10913                        }
10914                        let version_index = table.columns.len();
10915                        let version: i64 = row.get(version_index).map_err(|error| {
10916                            invalid(Self::sqlite_failure(&self.storage_failure, error))
10917                        })?;
10918                        if version < 1 {
10919                            return Err(invalid("image row version must be positive".into()));
10920                        }
10921                        ins.raw_bind_parameter(version_index + 1, version)
10922                            .map_err(|error| {
10923                                invalid(Self::sqlite_failure(&self.storage_failure, error))
10924                            })?;
10925                        ins.raw_execute().map_err(|error| {
10926                            invalid(Self::sqlite_failure(&self.storage_failure, error))
10927                        })?;
10928                        if let Some(mirror) = &mut mirror {
10929                            mirror
10930                                .raw_bind_parameter(version_index + 1, version)
10931                                .map_err(|error| {
10932                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
10933                                })?;
10934                            mirror.raw_execute().map_err(|error| {
10935                                invalid(Self::sqlite_failure(&self.storage_failure, error))
10936                            })?;
10937                        }
10938                        if reconcile_pending {
10939                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
10940                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
10941                            )?);
10942                        }
10943                        processed += 1;
10944                    }
10945                }
10946                if processed > 0 {
10947                    batch.table(&table.name);
10948                    self.overlay_dirty.set(true);
10949                }
10950                if reconcile_pending {
10951                    self.reconcile_imported_rows(table, &mut changed_keys)
10952                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10953                }
10954                self.apply_outbox_ops(
10955                    self.outbox
10956                        .iter()
10957                        .flat_map(|commit| &commit.ops)
10958                        .filter(|op| op.table == table.name),
10959                );
10960                self.overlay_dirty.set(false);
10961                self.finish_observation("syncular_image_chunk", batch)
10962                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10963                Ok(processed)
10964            })();
10965            let processed = match result {
10966                Ok(processed) => processed,
10967                Err(error) => {
10968                    self.rollback_observation("syncular_image_chunk");
10969                    self.overlay_dirty.set(was_dirty);
10970                    return Err(error);
10971                }
10972            };
10973            applied += processed;
10974            self.progress
10975                .update(|p| p.rows_processed = u64::from(applied));
10976            std::thread::yield_now();
10977            if i64::from(applied) == actual_count {
10978                break;
10979            }
10980        }
10981        Ok(applied)
10982    }
10983
10984    /// Restore only imported and pending row identities before FIFO replay. All pending
10985    /// identities participate: a server row can free a unique value for a different edit.
10986    /// The caller owns the chunk transaction and publishes its revision after replay.
10987    fn reconcile_imported_rows(
10988        &self,
10989        table: &TableSchema,
10990        keys: &mut Vec<SqlValue>,
10991    ) -> Result<(), String> {
10992        let visible = visible_table(&table.name);
10993        let base = base_table(&table.name);
10994        let pk = quote_ident(&table.primary_key);
10995        let predicate = row_id_predicate(table);
10996        let mut lookup = self.conn.prepare_cached(&format!(
10997            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10998        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10999        let mut deleted = HashSet::new();
11000        for op in self
11001            .outbox
11002            .iter()
11003            .flat_map(|commit| &commit.ops)
11004            .filter(|op| op.table == table.name)
11005        {
11006            if op.upsert {
11007                if let Some(values) = &op.values {
11008                    let key = json_to_column_value(
11009                        &table.columns[table.pk_index],
11010                        values.get(&table.primary_key),
11011                    )?;
11012                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
11013                }
11014            } else if deleted.insert(&op.row_id) {
11015                keys.extend(
11016                    lookup
11017                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
11018                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
11019                        .collect::<Result<Vec<_>, _>>()
11020                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
11021                );
11022            }
11023        }
11024        // Delete the complete affected set before restoring any base value, so
11025        // old optimistic unique values cannot collide with the new server rows.
11026        for chunk in keys.chunks(400) {
11027            let holes = vec!["?"; chunk.len()].join(",");
11028            self.conn
11029                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
11030                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
11031                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11032        }
11033        let updates = table
11034            .columns
11035            .iter()
11036            .map(|column| column.name.as_str())
11037            .chain(std::iter::once("_syncular_version"))
11038            .filter(|name| *name != table.primary_key)
11039            .map(|name| {
11040                let name = quote_ident(name);
11041                format!("{name}=excluded.{name}")
11042            })
11043            .collect::<Vec<_>>()
11044            .join(",");
11045        for chunk in keys.chunks(400) {
11046            let holes = vec!["?"; chunk.len()].join(",");
11047            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
11048                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
11049                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11050        }
11051        Ok(())
11052    }
11053
11054    // -- application-authorized local purge ---------------------------------------
11055
11056    fn compile_local_data_purge(
11057        &self,
11058        input: &LocalDataPurgeInput,
11059    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
11060        let invalid = |message: String| format!("sync.invalid_request: {message}");
11061        if input.purge_id.is_empty()
11062            || input.purge_id.len() > 128
11063            || !is_local_operation_code_like(&input.purge_id)
11064        {
11065            return Err(invalid(
11066                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
11067            ));
11068        }
11069        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
11070            return Err(invalid(format!(
11071                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
11072            )));
11073        }
11074
11075        let mut deduplicated: BTreeMap<
11076            String,
11077            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
11078        > = BTreeMap::new();
11079        for target in &input.targets {
11080            let table = self.schema.table(&target.table).ok_or_else(|| {
11081                invalid(format!(
11082                    "local purge names unknown table {:?}",
11083                    target.table
11084                ))
11085            })?;
11086            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
11087                return Err(invalid(format!(
11088                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
11089                    target.table
11090                )));
11091            }
11092            let mut selectors = Vec::with_capacity(target.selectors.len());
11093            let mut canonical_selectors = BTreeMap::new();
11094            for (column_name, raw_values) in &target.selectors {
11095                let Some((column_index, column)) = table
11096                    .columns
11097                    .iter()
11098                    .enumerate()
11099                    .find(|(_, column)| column.name == *column_name)
11100                else {
11101                    return Err(invalid(format!(
11102                        "local purge target {:?} names unknown column {:?}",
11103                        target.table, column_name
11104                    )));
11105                };
11106                let encrypted = table
11107                    .encrypted_columns
11108                    .iter()
11109                    .any(|candidate| candidate.index == column_index);
11110                if column.ty != ColumnType::String || encrypted {
11111                    return Err(invalid(format!(
11112                        "local purge selector {:?}.{:?} must be a plaintext string column",
11113                        target.table, column_name
11114                    )));
11115                }
11116                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
11117                    return Err(invalid(format!(
11118                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
11119                        target.table, column_name
11120                    )));
11121                }
11122                let mut values = raw_values.clone();
11123                values.sort();
11124                values.dedup();
11125                if values.iter().any(|value| {
11126                    value.is_empty()
11127                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
11128                        || !is_local_operation_code_like(value)
11129                }) {
11130                    return Err(invalid(format!(
11131                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
11132                    )));
11133                }
11134                selectors.push((column_name.clone(), values.clone()));
11135                canonical_selectors.insert(column_name.clone(), values);
11136            }
11137            let canonical = LocalDataPurgeTarget {
11138                table: target.table.clone(),
11139                selectors: canonical_selectors,
11140            };
11141            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11142            deduplicated.insert(
11143                key,
11144                (
11145                    CompiledLocalDataPurgeTarget {
11146                        table: target.table.clone(),
11147                        selectors,
11148                    },
11149                    canonical,
11150                ),
11151            );
11152        }
11153        let targets = deduplicated
11154            .values()
11155            .map(|(compiled, _)| compiled.clone())
11156            .collect::<Vec<_>>();
11157        let canonical = deduplicated
11158            .values()
11159            .map(|(_, target)| target.clone())
11160            .collect::<Vec<_>>();
11161        let canonical_plan =
11162            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11163        Ok((targets, canonical_plan))
11164    }
11165
11166    fn local_purge_base_row_ids(
11167        &self,
11168        targets: &[CompiledLocalDataPurgeTarget],
11169    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
11170        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11171        for target in targets {
11172            let table = self
11173                .schema
11174                .table(&target.table)
11175                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
11176            let mut clauses = Vec::with_capacity(target.selectors.len());
11177            let mut params = Vec::new();
11178            for (column, values) in &target.selectors {
11179                clauses.push(format!(
11180                    "{} IN ({})",
11181                    quote_ident(column),
11182                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
11183                ));
11184                params.extend(values.iter().cloned().map(SqlValue::Text));
11185            }
11186            let sql = format!(
11187                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
11188                quote_ident(&table.primary_key),
11189                base_table(&target.table),
11190                clauses.join(" AND ")
11191            );
11192            let mut statement = self
11193                .conn
11194                .prepare(&sql)
11195                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11196            let rows = statement
11197                .query_map(rusqlite::params_from_iter(params), |row| {
11198                    row.get::<_, String>(0)
11199                })
11200                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11201            let ids = by_table.entry(target.table.clone()).or_default();
11202            for row in rows {
11203                ids.insert(
11204                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
11205                );
11206            }
11207        }
11208        Ok(by_table)
11209    }
11210
11211    fn local_purge_values_match(
11212        target: &CompiledLocalDataPurgeTarget,
11213        values: &Map<String, Value>,
11214    ) -> bool {
11215        target.selectors.iter().all(|(column, allowed)| {
11216            values
11217                .get(column)
11218                .and_then(Value::as_str)
11219                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
11220        })
11221    }
11222
11223    /// Apply one host-authorized local security purge. The host MUST gate the
11224    /// corresponding subscriptions first; this primitive owns local SQLite
11225    /// cleanup and never grants/revokes server authority by itself.
11226    pub fn purge_local_data(
11227        &mut self,
11228        input: &LocalDataPurgeInput,
11229    ) -> Result<LocalDataPurgeResult, String> {
11230        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
11231        let meta_key = format!("localPurge:{}", input.purge_id);
11232        let applied_plan = self
11233            .conn
11234            .query_row(
11235                "SELECT value FROM _syncular_meta WHERE key = ?1",
11236                rusqlite::params![meta_key],
11237                |row| row.get::<_, String>(0),
11238            )
11239            .optional()
11240            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11241        if let Some(applied_plan) = applied_plan {
11242            if applied_plan != canonical_plan {
11243                return Err(format!(
11244                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
11245                    input.purge_id
11246                ));
11247            }
11248            return Ok(LocalDataPurgeResult {
11249                already_applied: true,
11250                purged_rows: 0,
11251                dropped_commits: 0,
11252            });
11253        }
11254
11255        // RFC 0005 D7: the container file cannot be a purge target (its name is
11256        // not in the running schema), so purge drops it as a fixed step, before
11257        // any mirror row is touched and unconditionally.
11258        let _ = self.drop_previous_version();
11259
11260        let prior_outbox = self.outbox.clone();
11261        let prior_rejection_count = self.rejections.len();
11262        let prior_overlay_dirty = self.overlay_dirty.get();
11263        self.begin_observation("syncular_local_purge")?;
11264        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
11265            let row_ids = self.local_purge_base_row_ids(&targets)?;
11266            let doomed = self
11267                .outbox
11268                .iter()
11269                .filter(|commit| {
11270                    commit.ops.iter().any(|operation| {
11271                        let matching_targets = targets
11272                            .iter()
11273                            .filter(|target| target.table == operation.table)
11274                            .collect::<Vec<_>>();
11275                        if matching_targets.is_empty() {
11276                            return false;
11277                        }
11278                        if row_ids
11279                            .get(&operation.table)
11280                            .is_some_and(|ids| ids.contains(&operation.row_id))
11281                        {
11282                            return true;
11283                        }
11284                        operation.values.as_ref().is_some_and(|values| {
11285                            matching_targets
11286                                .iter()
11287                                .any(|target| Self::local_purge_values_match(target, values))
11288                        })
11289                    })
11290                })
11291                .cloned()
11292                .collect::<Vec<_>>();
11293            let doomed_ids = doomed
11294                .iter()
11295                .map(|commit| commit.client_commit_id.clone())
11296                .collect::<BTreeSet<_>>();
11297            let mut batch = ChangeAccumulator::default();
11298            let mut rejections = Vec::new();
11299            for commit in &doomed {
11300                for operation in &commit.ops {
11301                    batch.table(&operation.table);
11302                }
11303                let results = commit
11304                    .ops
11305                    .iter()
11306                    .enumerate()
11307                    .map(|(op_index, operation)| {
11308                        let rejection = RejectionRecord {
11309                            client_commit_id: commit.client_commit_id.clone(),
11310                            op_index: op_index as i32,
11311                            code: "client.local_data_purged".to_owned(),
11312                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
11313                            retryable: false,
11314                            details: None,
11315                            operation: Some(CommitOperation::from(operation)),
11316                        };
11317                        rejections.push(rejection.clone());
11318                        CommitOperationOutcome::Error { rejection }
11319                    })
11320                    .collect::<Vec<_>>();
11321                self.persist_commit_outcome(
11322                    &commit.client_commit_id,
11323                    CommitOutcomeStatus::Rejected,
11324                    &results,
11325                    Some(&commit.ops),
11326                )?;
11327                self.delete_outbox_persisted(&commit.client_commit_id)?;
11328            }
11329            if !doomed.is_empty() {
11330                self.outbox
11331                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
11332                self.rejections.extend(rejections);
11333                self.prune_commit_outcomes()?;
11334                self.overlay_dirty.set(true);
11335                batch.status = true;
11336                batch.rejections = true;
11337                batch.outcomes = true;
11338            }
11339
11340            let mut purged_rows = 0usize;
11341            for (table, ids) in &row_ids {
11342                if ids.is_empty() {
11343                    continue;
11344                }
11345                batch.table(table);
11346                for row_id in ids {
11347                    self.delete_base_row(table, row_id)?;
11348                    purged_rows += 1;
11349                }
11350            }
11351            self.rebuild_overlay_if_dirty();
11352            self.delete_unreferenced_cached_blobs();
11353            self.conn
11354                .execute(
11355                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11356                    rusqlite::params![meta_key, canonical_plan],
11357                )
11358                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11359            Ok((
11360                batch,
11361                LocalDataPurgeResult {
11362                    already_applied: false,
11363                    purged_rows,
11364                    dropped_commits: doomed.len(),
11365                },
11366            ))
11367        })();
11368        let (batch, result) = match applied {
11369            Ok(value) => value,
11370            Err(error) => {
11371                self.rollback_observation("syncular_local_purge");
11372                self.outbox = prior_outbox;
11373                self.rejections.truncate(prior_rejection_count);
11374                self.overlay_dirty.set(prior_overlay_dirty);
11375                return Err(error);
11376            }
11377        };
11378        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
11379            self.rollback_observation("syncular_local_purge");
11380            self.outbox = prior_outbox;
11381            self.rejections.truncate(prior_rejection_count);
11382            self.overlay_dirty.set(prior_overlay_dirty);
11383            return Err(error);
11384        }
11385        Ok(result)
11386    }
11387
11388    /// Application-authorized recovery of the replicated projection. Unlike a
11389    /// local security purge, this retains the entire outbox, device identity,
11390    /// lease, outcomes, subscription registrations, and protected bookkeeping.
11391    /// The projection reset, subscription rewind, optimistic replay, and
11392    /// idempotency marker share one savepoint for interruption safety.
11393    pub fn rebootstrap_local_data(
11394        &mut self,
11395        input: &LocalDataRebootstrapInput,
11396    ) -> Result<LocalDataRebootstrapResult, String> {
11397        if input.rebootstrap_id.is_empty()
11398            || input.rebootstrap_id.len() > 128
11399            || !is_local_operation_code_like(&input.rebootstrap_id)
11400        {
11401            return Err(
11402                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
11403                    .to_owned(),
11404            );
11405        }
11406        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
11407        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
11408            let (retained_commits, reset_subscriptions) =
11409                decode_local_rebootstrap_receipt(&persisted)?;
11410            return Ok(LocalDataRebootstrapResult {
11411                already_applied: true,
11412                retained_commits,
11413                reset_subscriptions,
11414            });
11415        }
11416        if self.stopped || self.schema_floor.is_some() {
11417            return Err(
11418                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
11419                    .to_owned(),
11420            );
11421        }
11422
11423        let retained_commits = self.outbox.len();
11424        let reset_subscriptions = self.subs.len();
11425        let prior_subs = self.subs.clone();
11426        let prior_upgrading = self.upgrading;
11427        let prior_stopped = self.stopped;
11428        let prior_schema_floor = self.schema_floor.clone();
11429        let prior_overlay_dirty = self.overlay_dirty.get();
11430        let prior_sync_needed = self.sync_needed;
11431        let prior_sync_intents = self.sync_intent_queue.clone();
11432        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
11433
11434        self.begin_observation("syncular_local_rebootstrap")?;
11435        let mut batch = ChangeAccumulator::default();
11436        let applied = (|| -> Result<(), String> {
11437            self.run_schema_reset_observed(&mut batch, false, false)?;
11438            self.conn
11439                .execute(
11440                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11441                    rusqlite::params![meta_key, receipt],
11442                )
11443                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11444            self.sync_needed = true;
11445            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11446            batch.status = true;
11447            Ok(())
11448        })();
11449        if let Err(error) = applied {
11450            self.rollback_observation("syncular_local_rebootstrap");
11451            self.subs = prior_subs;
11452            self.upgrading = prior_upgrading;
11453            self.stopped = prior_stopped;
11454            self.schema_floor = prior_schema_floor;
11455            self.overlay_dirty.set(prior_overlay_dirty);
11456            self.sync_needed = prior_sync_needed;
11457            self.sync_intent_queue = prior_sync_intents;
11458            return Err(error);
11459        }
11460        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
11461            self.rollback_observation("syncular_local_rebootstrap");
11462            self.subs = prior_subs;
11463            self.upgrading = prior_upgrading;
11464            self.stopped = prior_stopped;
11465            self.schema_floor = prior_schema_floor;
11466            self.overlay_dirty.set(prior_overlay_dirty);
11467            self.sync_needed = prior_sync_needed;
11468            self.sync_intent_queue = prior_sync_intents;
11469            return Err(error);
11470        }
11471
11472        Ok(LocalDataRebootstrapResult {
11473            already_applied: false,
11474            retained_commits,
11475            reset_subscriptions,
11476        })
11477    }
11478
11479    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
11480
11481    /// Delete base rows matching the effective scopes; `Err(())` = no local
11482    /// scope-column mapping for a key (the fail-closed case).
11483    fn purge_scope_rows(
11484        &mut self,
11485        table_name: &str,
11486        effective: &[(String, Vec<String>)],
11487    ) -> Result<(), ()> {
11488        if effective.is_empty() {
11489            return Ok(());
11490        }
11491        let table = self.schema.table(table_name).ok_or(())?.clone();
11492        let mut clauses = Vec::new();
11493        let mut params: Vec<SqlValue> = Vec::new();
11494        for (variable, values) in effective {
11495            let column = table.scope_column(variable).ok_or(())?;
11496            let placeholders: Vec<String> = values
11497                .iter()
11498                .map(|v| {
11499                    params.push(SqlValue::Text(v.clone()));
11500                    "?".to_owned()
11501                })
11502                .collect();
11503            clauses.push(format!(
11504                "{} IN ({})",
11505                quote_ident(column),
11506                placeholders.join(", ")
11507            ));
11508        }
11509        let sql = format!(
11510            "DELETE FROM {} WHERE {}",
11511            base_table(table_name),
11512            clauses.join(" AND ")
11513        );
11514        self.overlay_dirty.set(true);
11515        self.conn
11516            .execute(&sql, rusqlite::params_from_iter(&params))
11517            .map_err(|_| ())?;
11518        self.conn
11519            .execute(
11520                &format!(
11521                    "DELETE FROM {} WHERE {}",
11522                    visible_table(table_name),
11523                    clauses.join(" AND ")
11524                ),
11525                rusqlite::params_from_iter(&params),
11526            )
11527            .map_err(|_| ())?;
11528        Ok(())
11529    }
11530
11531    /// §3.3: drop pending commits whose upserts provably land in the
11532    /// revoked effective scopes — whole-commit, never per-operation.
11533    fn drop_doomed_outbox(
11534        &mut self,
11535        table_name: &str,
11536        effective: &[(String, Vec<String>)],
11537    ) -> Result<bool, String> {
11538        if effective.is_empty() {
11539            return Ok(false);
11540        }
11541        let Some(table) = self.schema.table(table_name).cloned() else {
11542            return Ok(false);
11543        };
11544        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
11545        for (variable, values) in effective {
11546            match table.scope_column(variable) {
11547                Some(column) => mappings.push((column, values)),
11548                None => return Ok(false), // not provable without a mapping
11549            }
11550        }
11551        let doomed: Vec<OutboxCommit> = self
11552            .outbox
11553            .iter()
11554            .filter(|commit| {
11555                commit.ops.iter().any(|op| {
11556                    op.upsert
11557                        && op.table == table_name
11558                        && op.values.as_ref().is_some_and(|values| {
11559                            mappings
11560                                .iter()
11561                                .all(|(column, allowed)| match values.get(*column) {
11562                                    Some(Value::String(s)) => allowed.contains(s),
11563                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
11564                                    _ => false,
11565                                })
11566                        })
11567                })
11568            })
11569            .cloned()
11570            .collect();
11571        if doomed.is_empty() {
11572            return Ok(false);
11573        }
11574        let mut rejections = Vec::new();
11575        for commit in &doomed {
11576            let results = commit
11577                .ops
11578                .iter()
11579                .enumerate()
11580                .map(|(op_index, operation)| {
11581                    let rejection = RejectionRecord {
11582                        client_commit_id: commit.client_commit_id.clone(),
11583                        op_index: op_index as i32,
11584                        code: "sync.scope_revoked".to_owned(),
11585                        message: "the commit was dropped because its effective scope was revoked"
11586                            .to_owned(),
11587                        retryable: false,
11588                        details: None,
11589                        operation: Some(CommitOperation::from(operation)),
11590                    };
11591                    rejections.push(rejection.clone());
11592                    CommitOperationOutcome::Error { rejection }
11593                })
11594                .collect::<Vec<_>>();
11595            self.persist_commit_outcome(
11596                &commit.client_commit_id,
11597                CommitOutcomeStatus::Rejected,
11598                &results,
11599                Some(&commit.ops),
11600            )?;
11601            self.delete_outbox_persisted(&commit.client_commit_id)?;
11602        }
11603        self.prune_commit_outcomes()?;
11604        let doomed_ids = doomed
11605            .iter()
11606            .map(|commit| commit.client_commit_id.as_str())
11607            .collect::<BTreeSet<_>>();
11608        self.outbox
11609            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
11610        self.rejections.extend(rejections);
11611        self.overlay_dirty.set(true);
11612        Ok(true)
11613    }
11614
11615    // -- blobs (§5.9) ----------------------------------------------------------------
11616
11617    /// §5.9.7: hash bytes into the content address, cache them, queue the
11618    /// upload (flushed before the next push, B4). Returns the canonical
11619    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
11620    /// column value.
11621    pub fn upload_blob(
11622        &mut self,
11623        bytes: &[u8],
11624        media_type: Option<String>,
11625        name: Option<String>,
11626    ) -> Result<Value, String> {
11627        let blob_id = blob_id_for(bytes);
11628        let now = self.clock_now_ms();
11629        let tx = self
11630            .conn
11631            .transaction()
11632            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11633        tx.execute(
11634            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
11635             ON CONFLICT(blob_id) DO NOTHING",
11636            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
11637        )
11638        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11639        tx.execute(
11640            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
11641            rusqlite::params![blob_id, media_type, now],
11642        )
11643        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11644        tx.commit()
11645            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11646        self.enforce_blob_cache_cap();
11647        let mut obj = Map::new();
11648        obj.insert("blobId".to_owned(), Value::from(blob_id));
11649        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
11650        if let Some(mt) = media_type {
11651            obj.insert("mediaType".to_owned(), Value::from(mt));
11652        }
11653        if let Some(n) = name {
11654            obj.insert("name".to_owned(), Value::from(n));
11655        }
11656        Ok(Value::Object(obj))
11657    }
11658
11659    /// §5.9.7: resolve an owned blob body without driver encoding. A
11660    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
11661    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
11662    pub fn fetch_blob_bytes(
11663        &mut self,
11664        transport: &mut dyn Transport,
11665        blob_id_or_ref: &str,
11666    ) -> Result<FetchedBlob, (String, String)> {
11667        let simple = |m: String| ("client.failed".to_owned(), m);
11668        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
11669            blob_id_or_ref.to_owned()
11670        } else {
11671            let value: Value = serde_json::from_str(blob_id_or_ref)
11672                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
11673            value
11674                .get("blobId")
11675                .and_then(Value::as_str)
11676                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
11677                .to_owned()
11678        };
11679        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
11680            return Ok(cached);
11681        }
11682        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
11683        // blob.not_found) verbatim so the harness can assert on it. The
11684        // authorized endpoint serves bytes inline OR (always-issue, presign
11685        // configured) a signed url the client fetches directly — no host auth,
11686        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
11687        #[cfg(feature = "bench-internals")]
11688        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
11689        let bytes = match transport
11690            .blob_download(&blob_id)
11691            .map_err(|e| (e.code, e.message))?
11692        {
11693            BlobDownload::Bytes(bytes) => bytes,
11694            BlobDownload::Url {
11695                url,
11696                url_expires_at_ms,
11697            } => {
11698                // §5.9.5: MUST NOT start a fetch at/past expiry.
11699                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
11700                    return Err((
11701                        "sync.segment_expired".to_owned(),
11702                        format!(
11703                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
11704                        ),
11705                    ));
11706                }
11707                transport
11708                    .fetch_blob_url(&url)
11709                    .map_err(|e| (e.code, e.message))?
11710            }
11711        };
11712        #[cfg(feature = "bench-internals")]
11713        drop(download_phase);
11714        #[cfg(feature = "bench-internals")]
11715        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
11716        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
11717        if blob_id_for(&bytes) != blob_id {
11718            return Err(simple(format!(
11719                "blob content address mismatch for {blob_id}"
11720            )));
11721        }
11722        #[cfg(feature = "bench-internals")]
11723        drop(validate_phase);
11724        #[cfg(feature = "bench-internals")]
11725        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
11726        let now = self.clock_now_ms();
11727        self.conn
11728            .execute(
11729                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
11730                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
11731            )
11732            .map_err(|e| simple(e.to_string()))?;
11733        self.conn
11734            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
11735            .map_err(|e| simple(e.to_string()))?;
11736        self.enforce_blob_cache_cap();
11737        #[cfg(feature = "bench-internals")]
11738        drop(insert_phase);
11739        self.get_cached_blob_bytes(&blob_id)
11740            .map_err(simple)?
11741            .ok_or_else(|| simple("blob cache write failed".to_owned()))
11742    }
11743
11744    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
11745        #[cfg(feature = "bench-internals")]
11746        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
11747        let mut stmt = self
11748            .conn
11749            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
11750            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11751        let mut rows = stmt
11752            .query(rusqlite::params![blob_id])
11753            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11754        if let Some(row) = rows
11755            .next()
11756            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
11757        {
11758            let bytes: Vec<u8> = row
11759                .get(0)
11760                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11761            let byte_length: i64 = row
11762                .get(1)
11763                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11764            let media_type: Option<String> = row
11765                .get(2)
11766                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11767            return Ok(Some(FetchedBlob {
11768                blob_id: blob_id.to_owned(),
11769                byte_length,
11770                bytes,
11771                media_type,
11772            }));
11773        }
11774        Ok(None)
11775    }
11776
11777    /// §5.9.7 B4: upload every queued blob before push.
11778    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
11779        let pending: Vec<(String, Option<String>)> = {
11780            let mut stmt = self
11781                .conn
11782                .prepare(
11783                    "SELECT blob_id, media_type FROM (
11784                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
11785                      UNION ALL
11786                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
11787                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
11788                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
11789                    ) ORDER BY created_at_ms, blob_id",
11790                )
11791                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11792            let rows = stmt
11793                .query_map([], |row| {
11794                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
11795                })
11796                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11797            rows.collect::<Result<_, _>>()
11798                .map_err(|error| match error {
11799                    rusqlite::Error::InvalidColumnType(..)
11800                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
11801                        "sync.local_corrupt",
11802                        "Pending blob upload metadata is invalid",
11803                    ),
11804                    _ => TransportError::new("client.failed", error.to_string()),
11805                })?
11806        };
11807        for (blob_id, media_type) in pending {
11808            let stored: Option<(SqlValue, SqlValue)> = self
11809                .conn
11810                .query_row(
11811                    "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
11812                    rusqlite::params![blob_id],
11813                    |row| Ok((row.get(0)?, row.get(1)?)),
11814                )
11815                .optional()
11816                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11817            let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
11818                return Err(TransportError::new(
11819                    "sync.local_corrupt",
11820                    "Pending blob upload body is missing or corrupt",
11821                ));
11822            };
11823            if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
11824                return Err(TransportError::new(
11825                    "sync.local_corrupt",
11826                    "Pending blob upload body is missing or corrupt",
11827                ));
11828            }
11829            self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
11830            self.conn
11831                .execute(
11832                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
11833                    rusqlite::params![blob_id],
11834                )
11835                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11836        }
11837        Ok(())
11838    }
11839
11840    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
11841    /// grant when the transport supports it, else streaming through the direct
11842    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
11843    /// PUTs direct with no host auth; on a grant PUT failure the client streams
11844    /// through the direct endpoint — a *different, host-authenticated
11845    /// capability*, not a fall-through of the grant's authority.
11846    fn upload_one(
11847        &self,
11848        transport: &mut dyn Transport,
11849        blob_id: &str,
11850        bytes: &[u8],
11851        media_type: Option<&str>,
11852    ) -> Result<(), TransportError> {
11853        match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
11854            BlobUploadGrant::Present => return Ok(()), // idempotent §5.9.3
11855            BlobUploadGrant::Url {
11856                url,
11857                url_expires_at_ms,
11858            } => {
11859                let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
11860                if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
11861                    return Ok(());
11862                }
11863                // Failed/expired grant PUT — stream through the direct endpoint.
11864            }
11865            BlobUploadGrant::None => {
11866                // No presign store — stream through the direct endpoint.
11867            }
11868        }
11869        transport.blob_upload(blob_id, bytes, media_type)
11870    }
11871
11872    fn visible_blob_ids_sql(&self) -> String {
11873        let mut selects = Vec::new();
11874        for table in &self.schema.tables {
11875            for column in table
11876                .columns
11877                .iter()
11878                .filter(|column| column.ty == ColumnType::BlobRef)
11879            {
11880                let identifier = quote_ident(&column.name);
11881                selects.push(format!(
11882                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
11883                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
11884                     AND json_type({identifier}, '$.blobId') = 'text'",
11885                    quote_ident(&table.name),
11886                ));
11887            }
11888        }
11889        if selects.is_empty() {
11890            "SELECT NULL AS blob_id WHERE 0".to_owned()
11891        } else {
11892            selects.join(" UNION ")
11893        }
11894    }
11895
11896    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
11897    /// staged uploads pin bodies directly; no derived refcount is stored.
11898    fn enforce_blob_cache_cap(&self) {
11899        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
11900            return;
11901        };
11902        let mut total: i64 = self
11903            .conn
11904            .query_row(
11905                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
11906                [],
11907                |row| row.get(0),
11908            )
11909            .unwrap_or(0);
11910        if total <= max_bytes {
11911            return;
11912        }
11913        let query = format!(
11914            "SELECT blob_id, byte_length FROM _syncular_blobs
11915             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11916               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11917               AND blob_id NOT IN ({})
11918             ORDER BY created_at_ms ASC, blob_id ASC",
11919            self.visible_blob_ids_sql(),
11920        );
11921        let candidates: Vec<(String, i64)> = {
11922            let Ok(mut stmt) = self.conn.prepare(&query) else {
11923                return;
11924            };
11925            let Ok(rows) = stmt.query_map([], |row| {
11926                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11927            }) else {
11928                return;
11929            };
11930            rows.filter_map(Result::ok).collect()
11931        };
11932        for (blob_id, byte_length) in candidates {
11933            if total <= max_bytes {
11934                break;
11935            }
11936            let _ = self.conn.execute(
11937                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
11938                rusqlite::params![blob_id],
11939            );
11940            total -= byte_length;
11941        }
11942    }
11943
11944    fn delete_unreferenced_cached_blobs(&mut self) {
11945        #[cfg(feature = "bench-internals")]
11946        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11947        if !self.schema_has_blobs() {
11948            return;
11949        }
11950        self.rebuild_overlay_if_dirty();
11951        let _ = self.conn.execute(
11952            &format!(
11953                "DELETE FROM _syncular_blobs
11954                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11955                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11956                   AND blob_id NOT IN ({})",
11957                self.visible_blob_ids_sql(),
11958            ),
11959            [],
11960        );
11961    }
11962
11963    // -- local row storage ----------------------------------------------------------
11964
11965    /// The cached per-table primary-key upsert SQL for `full_table` (see
11966    /// the `insert_sql` field: built once, reused per row).
11967    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11968        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11969            return sql.clone();
11970        }
11971        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11972        columns.push(quote_ident("_syncular_version"));
11973        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11974        let primary_key = quote_ident(&table.primary_key);
11975        let updates = columns
11976            .iter()
11977            .filter(|column| **column != primary_key)
11978            .map(|column| format!("{column}=excluded.{column}"))
11979            .collect::<Vec<_>>()
11980            .join(", ");
11981        let sql = format!(
11982            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
11983            columns.join(", "),
11984            placeholders.join(", ")
11985        );
11986        self.insert_sql
11987            .borrow_mut()
11988            .insert(full_table.to_owned(), sql.clone());
11989        sql
11990    }
11991
11992    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
11993        self.overlay_dirty.set(true);
11994        self.write_row(&base_table(table_name), table_name, row, version)
11995    }
11996
11997    fn write_row(
11998        &self,
11999        full_table: &str,
12000        table_name: &str,
12001        row: &Row,
12002        version: i64,
12003    ) -> Result<(), String> {
12004        #[cfg(feature = "bench-internals")]
12005        let _phase = self.benchmark_phases.start(Phase::RowWrite);
12006        let table = self
12007            .schema
12008            .table(table_name)
12009            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
12010        let sql = self.insert_row_sql(full_table, table);
12011        let mut stmt = self
12012            .conn
12013            .prepare_cached(&sql)
12014            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12015        let params = row
12016            .iter()
12017            .map(RowParam::Cell)
12018            .chain(std::iter::once(RowParam::Version(version)));
12019        stmt.execute(rusqlite::params_from_iter(params))
12020            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12021        Ok(())
12022    }
12023
12024    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
12025        let table = self
12026            .schema
12027            .table(table_name)
12028            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
12029        self.overlay_dirty.set(true);
12030        let sql = format!(
12031            "DELETE FROM {} WHERE {}",
12032            base_table(table_name),
12033            row_id_predicate(table)
12034        );
12035        let mut stmt = self
12036            .conn
12037            .prepare_cached(&sql)
12038            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12039        stmt.execute(rusqlite::params![row_id])
12040            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12041        Ok(())
12042    }
12043
12044    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
12045    /// the outbox changed since the last rebuild (the no-op sync round).
12046    fn rebuild_overlay_if_dirty(&mut self) {
12047        if self.overlay_dirty.get() {
12048            self.rebuild_overlay();
12049        }
12050    }
12051
12052    /// §7.1: local reads see outbox state applied optimistically — rebuild
12053    /// every visible table as (base server state) + (pending outbox replay
12054    /// on top). Optimistic rows carry version `-1`.
12055    fn rebuild_overlay(&mut self) {
12056        #[cfg(feature = "bench-internals")]
12057        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
12058        #[cfg(test)]
12059        self.overlay_rebuild_count
12060            .set(self.overlay_rebuild_count.get() + 1);
12061        self.exec("SAVEPOINT syncular_overlay");
12062        for table in self.schema.tables.clone() {
12063            for index in &table.fts_indexes {
12064                let _ = self.drop_fts_triggers(index);
12065            }
12066            let visible = visible_table(&table.name);
12067            let base = base_table(&table.name);
12068            self.exec(&format!("DELETE FROM {visible}"));
12069            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
12070        }
12071        for commit in &self.outbox {
12072            self.apply_outbox_ops(&commit.ops);
12073        }
12074        for table in self.schema.tables.clone() {
12075            for index in &table.fts_indexes {
12076                let _ = self.rebuild_fts_projection(&table, index);
12077                let _ = self.create_fts_triggers(&table, index);
12078            }
12079        }
12080        self.exec("RELEASE syncular_overlay");
12081        self.overlay_dirty.set(false);
12082    }
12083
12084    /// §7.1 replay: every operation is applied over the current visible
12085    /// state. An operation that no longer applies (a server row now holds its
12086    /// unique value) stays pending and invisible until a later replay admits
12087    /// it or the server answers its push.
12088    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
12089        #[cfg(feature = "bench-internals")]
12090        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
12091        for op in ops {
12092            #[cfg(feature = "bench-internals")]
12093            if let Some(phase) = &mut phase {
12094                phase.unit();
12095            }
12096            let _ = self.apply_outbox_op(op);
12097        }
12098    }
12099
12100    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
12101        let Some(table) = self.schema.table(&op.table) else {
12102            return Ok(());
12103        };
12104        let visible = visible_table(&table.name);
12105        if !op.upsert {
12106            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
12107            self.conn
12108                .prepare_cached(&sql)
12109                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
12110                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12111            return Ok(());
12112        }
12113        let Some(values) = op.values.as_ref() else {
12114            return Ok(());
12115        };
12116        // §7.1: the overlay applies the operation's PRESENT columns
12117        // over the current local row. A partial operation over an
12118        // absent local row leaves it absent — the server answers it
12119        // with sync.row_deleted or sync.row_missing (§6.2).
12120        let full = table
12121            .columns
12122            .iter()
12123            .all(|column| values.contains_key(&column.name));
12124        let local = self.visible_row(table, &op.row_id);
12125        if local.is_none() && !full {
12126            return Ok(());
12127        }
12128        let mut row: Row = Vec::with_capacity(table.columns.len());
12129        for (index, column) in table.columns.iter().enumerate() {
12130            let incoming = match values.get(&column.name) {
12131                Some(raw) => json_to_column_value(column, Some(raw)),
12132                None => Ok(local
12133                    .as_ref()
12134                    .and_then(|(values, _)| values.get(index).cloned())
12135                    .flatten()),
12136            };
12137            match incoming {
12138                Ok(value) => row.push(value),
12139                Err(_) => return Ok(()),
12140            }
12141        }
12142        let version = local.as_ref().map_or(-1, |(_, version)| *version);
12143        self.write_row(&visible, &table.name, &row, version)
12144            .map_err(|error| {
12145                if error.starts_with("UNIQUE constraint failed:") {
12146                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
12147                } else {
12148                    error
12149                }
12150            })
12151    }
12152
12153    /// The visible (optimistic) row's values plus version, or `None` when the
12154    /// row is absent from the overlay.
12155    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
12156        let sql = format!(
12157            "SELECT * FROM {} WHERE {}",
12158            visible_table(&table.name),
12159            row_id_predicate(table)
12160        );
12161        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
12162        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
12163        let row = rows.next().ok()??;
12164        let mut values = Vec::with_capacity(table.columns.len());
12165        for (index, column) in table.columns.iter().enumerate() {
12166            let raw = row.get_ref(index).ok()?;
12167            let json = sql_ref_to_json(column, raw);
12168            values.push(json_to_column_value(column, Some(&json)).ok()?);
12169        }
12170        let version: i64 = row.get(table.columns.len()).ok()?;
12171        Some((values, version))
12172    }
12173
12174    fn exec(&self, sql: &str) {
12175        let _ = self.conn.execute_batch(sql);
12176    }
12177
12178    // -- realtime (§8) ---------------------------------------------------------------
12179
12180    /// §8.8: the configured realtime policy.
12181    #[must_use]
12182    pub fn realtime_policy(&self) -> RealtimePolicy {
12183        self.realtime_policy
12184    }
12185
12186    /// §8.8: the explicit realtime availability state.
12187    #[must_use]
12188    pub fn realtime_state(&self) -> RealtimeState {
12189        self.realtime_state
12190    }
12191
12192    /// §8.8: set the realtime policy. Refused while the socket is connected:
12193    /// a policy change never silently re-routes an ownership state.
12194    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
12195        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
12196            return Err(
12197                "sync.invalid_request: disconnect realtime before changing the realtime policy"
12198                    .to_owned(),
12199            );
12200        }
12201        self.realtime_policy = policy;
12202        match policy {
12203            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
12204            RealtimePolicy::Required | RealtimePolicy::Optional
12205                if self.realtime_state == RealtimeState::Disabled =>
12206            {
12207                self.set_realtime_state(RealtimeState::Disconnected, None);
12208            }
12209            RealtimePolicy::Required | RealtimePolicy::Optional => {}
12210        }
12211        Ok(())
12212    }
12213
12214    /// Record an availability transition, keeping the reason code bounded and
12215    /// code-like (§7.6) and clearing it where it cannot apply.
12216    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
12217        self.realtime_state = state;
12218        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
12219        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
12220            self.realtime_reason_code = None;
12221            self.realtime_retry_delay_ms = None;
12222        }
12223    }
12224
12225    #[must_use]
12226    fn realtime_connected(&self) -> bool {
12227        self.realtime_state == RealtimeState::Connected
12228    }
12229
12230    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
12231        if self.realtime_policy == RealtimePolicy::Off {
12232            return Err(
12233                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
12234            );
12235        }
12236        if self.realtime_connected() {
12237            return Ok(());
12238        }
12239        self.realtime_state = RealtimeState::Connecting;
12240        match transport.realtime_connect_for_client(&self.client_id) {
12241            Ok(()) => {
12242                self.set_realtime_state(RealtimeState::Connected, None);
12243                Ok(())
12244            }
12245            Err(error) => {
12246                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
12247                Err(format!("{}: {}", error.code, error.message))
12248            }
12249        }
12250    }
12251
12252    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
12253        // A deliberately disconnected or policy-disabled client has nothing
12254        // to release; a `lost` socket still does (the host owns the handle).
12255        if self.realtime_state == RealtimeState::Disconnected
12256            || self.realtime_state == RealtimeState::Disabled
12257        {
12258            return;
12259        }
12260        let _ = transport.realtime_close();
12261        self.set_realtime_state(
12262            if self.realtime_policy == RealtimePolicy::Off {
12263                RealtimeState::Disabled
12264            } else {
12265                RealtimeState::Disconnected
12266            },
12267            None,
12268        );
12269        self.presence.clear(); // §8.6.1: presence is per-connection
12270    }
12271
12272    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
12273    /// document for `scope_key`. Requires a live socket; the document is
12274    /// ephemeral (lost on disconnect). Authorization is the connection's
12275    /// registration (§8.6.3) — an unheld key is rejected loudly by the
12276    /// server with `presence.forbidden`.
12277    pub fn set_presence(
12278        &mut self,
12279        transport: &mut dyn Transport,
12280        scope_key: &str,
12281        doc: Option<&Value>,
12282    ) -> Result<(), String> {
12283        if !self.realtime_connected() {
12284            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
12285        }
12286        let text = encode_presence_publish(scope_key, doc);
12287        transport
12288            .realtime_send(&text)
12289            .map_err(|e| format!("{}: {}", e.code, e.message))
12290    }
12291
12292    /// §8.6: the peers currently present on a scope key (ephemeral).
12293    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
12294        self.presence
12295            .get(scope_key)
12296            .map(|peers| peers.values().cloned().collect())
12297            .unwrap_or_default()
12298    }
12299
12300    /// §8.6 apply an inbound presence fanout to the local map.
12301    fn apply_presence(
12302        &mut self,
12303        scope_key: String,
12304        kind: Option<PresenceKind>,
12305        actor_id: Option<String>,
12306        client_id: Option<String>,
12307        doc: Option<Value>,
12308        error: Option<String>,
12309    ) {
12310        // The publisher-directed error variant is out-of-band; nothing to
12311        // record in the peer map.
12312        if error.is_some() {
12313            return;
12314        }
12315        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
12316            return;
12317        };
12318        let peer_key = format!("{actor_id} {client_id}");
12319        match kind {
12320            PresenceKind::Leave => {
12321                if let Some(peers) = self.presence.get_mut(&scope_key) {
12322                    peers.remove(&peer_key);
12323                    if peers.is_empty() {
12324                        self.presence.remove(&scope_key);
12325                    }
12326                }
12327            }
12328            _ => {
12329                let doc = match doc {
12330                    Some(Value::Object(_)) => doc.unwrap(),
12331                    _ => return,
12332                };
12333                self.presence.entry(scope_key).or_default().insert(
12334                    peer_key,
12335                    PresencePeer {
12336                        actor_id,
12337                        client_id,
12338                        doc,
12339                    },
12340                );
12341            }
12342        }
12343    }
12344
12345    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
12346    pub fn on_realtime_text(&mut self, text: &str) {
12347        match parse_control(text) {
12348            Ok(ControlMessage::Hello { requires_sync, .. }) => {
12349                if requires_sync {
12350                    // §8.1: pull before trusting the socket for continuity.
12351                    self.set_sync_needed(true, true);
12352                }
12353            }
12354            Ok(ControlMessage::Presence {
12355                scope_key,
12356                kind,
12357                actor_id,
12358                client_id,
12359                doc,
12360                error,
12361                ..
12362            }) => {
12363                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
12364            }
12365            Ok(ControlMessage::Wake { .. }) => {
12366                // §8.3: any wake-up means "run a pull soon", never data.
12367                self.set_sync_needed(true, true);
12368            }
12369            _ => {}
12370        }
12371    }
12372
12373    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
12374    /// a pull response per section; an unapplied delta is a wake-up.
12375    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
12376        if self.stopped {
12377            return;
12378        }
12379        #[cfg(feature = "bench-internals")]
12380        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
12381        let message = match decode_message(bytes) {
12382            Ok(m) if m.msg_kind == MsgKind::Response => m,
12383            _ => {
12384                self.set_sync_needed(true, true);
12385                return;
12386            }
12387        };
12388        #[cfg(feature = "bench-internals")]
12389        drop(decode_phase);
12390        let mut frames = message.frames.into_iter();
12391        let mut applied_cursor: Option<i64> = None;
12392        let mut any_covered = false;
12393        let mut dropped = false;
12394        while let Some(frame) = frames.next() {
12395            let Frame::SubStart {
12396                id,
12397                status,
12398                effective_scopes,
12399                ..
12400            } = frame
12401            else {
12402                continue;
12403            };
12404            let mut body = Vec::new();
12405            let mut next_cursor: Option<i64> = None;
12406            for inner in frames.by_ref() {
12407                match inner {
12408                    Frame::SubEnd {
12409                        next_cursor: nc, ..
12410                    } => {
12411                        next_cursor = Some(nc);
12412                        break;
12413                    }
12414                    Frame::Unknown { .. } => {}
12415                    other => body.push(other),
12416                }
12417            }
12418            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
12419                dropped = true;
12420                continue;
12421            };
12422            let sub = &self.subs[sub_index];
12423            // §8.2: only locally active, not mid-bootstrap subscriptions
12424            // apply; skipped sections are repaired by the next pull.
12425            if status != SubStatus::Active
12426                || sub.state != SubState::Active
12427                || sub.bootstrap_state.is_some()
12428                || !sub.synced_once
12429            {
12430                dropped = true;
12431                continue;
12432            }
12433            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
12434                // Idempotent redelivery of an already-covered window.
12435                any_covered = true;
12436                continue;
12437            }
12438            let previous_effective = self.subs[sub_index].effective.clone();
12439            let previous_cursor = self.subs[sub_index].cursor;
12440            self.subs[sub_index].effective = Some(effective_scopes);
12441            let mut failed = false;
12442            for inner in body {
12443                match inner {
12444                    Frame::Commit {
12445                        tables, changes, ..
12446                    } => {
12447                        if self.apply_commit_frame(&tables, &changes).is_err() {
12448                            failed = true;
12449                            break;
12450                        }
12451                    }
12452                    _ => {
12453                        failed = true;
12454                        break;
12455                    }
12456                }
12457            }
12458            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
12459                self.apply_sub_end(sub_index, cursor, None)
12460                    .ok()
12461                    .map(|()| cursor)
12462            });
12463            let Some(next_cursor) = committed else {
12464                self.subs[sub_index].effective = previous_effective;
12465                self.subs[sub_index].cursor = previous_cursor;
12466                dropped = true;
12467                continue;
12468            };
12469            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
12470        }
12471        if let Some(cursor) = applied_cursor {
12472            // §8.2 ack point: the highest applied SUB_END.nextCursor.
12473            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12474            let _ = transport.realtime_send(&ack);
12475        } else if !any_covered || dropped {
12476            // §8.2: a delta not applied at all is treated as a wake-up.
12477            self.set_sync_needed(true, true);
12478        }
12479    }
12480
12481    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
12482    /// cursor across active, non-bootstrapping subscriptions that have
12483    /// synced at least once. No such subscription, no ack.
12484    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
12485        if !self.realtime_connected() {
12486            return;
12487        }
12488        let floor = self
12489            .subs
12490            .iter()
12491            .filter(|s| {
12492                s.state == SubState::Active
12493                    && s.bootstrap_state.is_none()
12494                    && s.synced_once
12495                    && s.cursor >= 0
12496            })
12497            .map(|s| s.cursor)
12498            .min();
12499        if let Some(cursor) = floor {
12500            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12501            let _ = transport.realtime_send(&ack);
12502        }
12503    }
12504}
12505
12506#[cfg(test)]
12507mod previous_version_wiring_tests {
12508    use super::*;
12509    use serde_json::{json, Value};
12510
12511    fn previous_version_schema(version: i32) -> Value {
12512        json!({
12513            "version": version,
12514            "tables": [{
12515                "name": "tasks",
12516                "primaryKey": "id",
12517                "columns": [
12518                    { "name": "id", "type": "string", "nullable": false },
12519                    { "name": "note", "type": "string", "nullable": true }
12520                ],
12521                "scopes": []
12522            }]
12523        })
12524    }
12525
12526    fn previous_version_limits() -> crate::api::ClientLimits {
12527        crate::api::ClientLimits {
12528            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
12529                enabled: true,
12530                ..Default::default()
12531            }),
12532            ..Default::default()
12533        }
12534    }
12535
12536    /// The configuration the live client carries, for a direct capture call.
12537    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
12538        previous_version_limits()
12539            .previous_version_context
12540            .expect("configured")
12541    }
12542
12543    /// Capture the container the way a bump would, so a read test starts from a
12544    /// container this client's own state will then decide about.
12545    fn recapture_previous_version(client: &SyncClient) {
12546        let replica_path = client
12547            .previous_version_replica_path()
12548            .expect("file-backed replica");
12549        let outcome = crate::previous_version::capture_previous_version_from_replica(
12550            &client.conn,
12551            &replica_path,
12552            2,
12553            2,
12554            &previous_version_config(),
12555            1,
12556        )
12557        .expect("capture");
12558        assert!(matches!(
12559            outcome,
12560            crate::previous_version::CaptureOutcome::Captured(_)
12561        ));
12562    }
12563
12564    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
12565        Subscription {
12566            id: "wiring-sub".to_owned(),
12567            table: "tasks".to_owned(),
12568            requested: Vec::new(),
12569            params: None,
12570            cursor,
12571            bootstrap_state: bootstrap_state.map(str::to_owned),
12572            state,
12573            reason_code: None,
12574            effective: None,
12575            synced_once: false,
12576        }
12577    }
12578
12579    #[test]
12580    fn previous_version_read_reasons_come_from_live_client_state() {
12581        use crate::previous_version::{
12582            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
12583        };
12584        let path = std::env::temp_dir()
12585            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
12586            .to_string_lossy()
12587            .into_owned();
12588        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
12589        {
12590            let mut setup = SyncClient::open_path(
12591                "wiring".to_owned(),
12592                &previous_version_schema(1),
12593                previous_version_limits(),
12594                &path,
12595            )
12596            .expect("v1 install");
12597            setup
12598                .mutate(vec![Mutation::Upsert {
12599                    table: "tasks".to_owned(),
12600                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12601                    base_version: None,
12602                }])
12603                .expect("seed a row");
12604        }
12605        let mut client = SyncClient::open_path(
12606            "wiring".to_owned(),
12607            &previous_version_schema(2),
12608            previous_version_limits(),
12609            &path,
12610        )
12611        .expect("v2 bump captures");
12612        assert!(std::path::Path::new(&container).exists());
12613        let spec = PreviousVersionReadSpec {
12614            table: "tasks".to_owned(),
12615            ..Default::default()
12616        };
12617
12618        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
12619        recapture_previous_version(&client);
12620        client.lease_state = Some(LeaseState {
12621            lease_id: None,
12622            expires_at_ms: None,
12623            error_code: Some("lease.expired".to_owned()),
12624        });
12625        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12626        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12627        assert!(!snapshot.available);
12628        assert!(!std::path::Path::new(&container).exists());
12629
12630        // An expired lease uses the client clock (injected, no sleeps).
12631        recapture_previous_version(&client);
12632        client.set_now_ms(1_000);
12633        client.lease_state = Some(LeaseState {
12634            lease_id: Some("lease".to_owned()),
12635            expires_at_ms: Some(1_000),
12636            error_code: None,
12637        });
12638        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12639        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12640        assert!(!std::path::Path::new(&container).exists());
12641        client.lease_state = None;
12642
12643        // A revoked subscription ⇒ scope-revoked.
12644        recapture_previous_version(&client);
12645        client.subs.push(subscription(SubState::Revoked, 0, None));
12646        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12647        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
12648        assert!(!std::path::Path::new(&container).exists());
12649        client.subs.clear();
12650
12651        // Coverage completion: every ACTIVE subscription has a cursor and no
12652        // resume token — a still-resuming subscription is not completion.
12653        recapture_previous_version(&client);
12654        client
12655            .subs
12656            .push(subscription(SubState::Active, 0, Some("resume")));
12657        assert!(!client.previous_version_coverage_complete());
12658        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12659        assert!(snapshot.available);
12660        assert_eq!(snapshot.previous_version, Some(2));
12661        assert_eq!(snapshot.rows.len(), 1);
12662
12663        recapture_previous_version(&client);
12664        client.subs[0].bootstrap_state = None;
12665        assert!(client.previous_version_coverage_complete());
12666        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12667        assert_eq!(
12668            snapshot.reason,
12669            Some(PreviousVersionReason::CoverageComplete)
12670        );
12671        assert!(!std::path::Path::new(&container).exists());
12672        client.subs.clear();
12673
12674        // With no active subscription at all, coverage is NOT complete.
12675        assert!(!client.previous_version_coverage_complete());
12676
12677        drop(client);
12678        let _ = std::fs::remove_file(&path);
12679    }
12680
12681    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
12682    /// come from live state; this proves the OTHER caller —
12683    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
12684    /// actually removes the container for lease-inactivity, coverage
12685    /// completion and scope revocation, and leaves it alone while the
12686    /// replacement bootstrap is still resuming.
12687    #[test]
12688    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
12689        let path = std::env::temp_dir()
12690            .join(format!(
12691                "syncular-prev-lifetime-{}.db",
12692                uuid::Uuid::new_v4()
12693            ))
12694            .to_string_lossy()
12695            .into_owned();
12696        let container = format!(
12697            "{path}{}",
12698            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
12699        );
12700        let exists = || std::path::Path::new(&container).exists();
12701        {
12702            let mut setup = SyncClient::open_path(
12703                "lifetime".to_owned(),
12704                &previous_version_schema(1),
12705                previous_version_limits(),
12706                &path,
12707            )
12708            .expect("v1 install");
12709            setup
12710                .mutate(vec![Mutation::Upsert {
12711                    table: "tasks".to_owned(),
12712                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12713                    base_version: None,
12714                }])
12715                .expect("seed a row");
12716        }
12717        let mut client = SyncClient::open_path(
12718            "lifetime".to_owned(),
12719            &previous_version_schema(2),
12720            previous_version_limits(),
12721            &path,
12722        )
12723        .expect("v2 bump captures");
12724        // Pin the clock so the 24h TTL never fires in this test (the TTL has
12725        // its own coverage); the capture record's createdAtMs is 1.
12726        client.set_now_ms(1_000);
12727        assert!(exists());
12728
12729        // §7.3.5 lease error ⇒ the lifetime check drops the container.
12730        client.lease_state = Some(LeaseState {
12731            lease_id: None,
12732            expires_at_ms: None,
12733            error_code: Some("lease.expired".to_owned()),
12734        });
12735        client.previous_version_lifetime_check();
12736        assert!(!exists());
12737        client.lease_state = None;
12738
12739        // §7.4.5 coverage completion (every active sub has a cursor and no
12740        // resume token) ⇒ drop.
12741        recapture_previous_version(&client);
12742        client.subs.push(subscription(SubState::Active, 0, None));
12743        client.previous_version_lifetime_check();
12744        assert!(!exists());
12745        client.subs.clear();
12746
12747        // §3.3 scope revocation ⇒ drop.
12748        recapture_previous_version(&client);
12749        client.subs.push(subscription(SubState::Revoked, 0, None));
12750        client.previous_version_lifetime_check();
12751        assert!(!exists());
12752        client.subs.clear();
12753
12754        // Negative control: a still-resuming active subscription is NOT
12755        // coverage completion, so the container survives the same call.
12756        recapture_previous_version(&client);
12757        client
12758            .subs
12759            .push(subscription(SubState::Active, 0, Some("resume")));
12760        client.previous_version_lifetime_check();
12761        assert!(exists());
12762        client.subs.clear();
12763
12764        drop(client);
12765        let _ = std::fs::remove_file(&path);
12766        let _ = std::fs::remove_file(&container);
12767        let _ = std::fs::remove_file(format!("{container}-wal"));
12768        let _ = std::fs::remove_file(format!("{container}-shm"));
12769    }
12770}