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        let mut transport = ServerErrorTransport {
1122            code: "sync.invalid_request",
1123            retryable: false,
1124        };
1125        assert!(matches!(
1126            client.sync(&mut transport),
1127            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1128        ));
1129        assert!(client.drain_sync_intents().is_empty());
1130    }
1131
1132    #[test]
1133    fn background_retry_deadlines_back_off_and_reset() {
1134        let mut client = client();
1135        client.schedule_background_retry();
1136        assert!(matches!(
1137            client.drain_sync_intents().as_slice(),
1138            [SyncIntent::Background { delay_ms: 250 }]
1139        ));
1140        client.schedule_background_retry();
1141        assert!(matches!(
1142            client.drain_sync_intents().as_slice(),
1143            [SyncIntent::Background { delay_ms: 500 }]
1144        ));
1145        client.reset_background_retry();
1146        client.schedule_background_retry();
1147        assert!(matches!(
1148            client.drain_sync_intents().as_slice(),
1149            [SyncIntent::Background { delay_ms: 250 }]
1150        ));
1151    }
1152
1153    #[test]
1154    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1155        let mut client = client();
1156        client
1157            .subscribe(
1158                "epoch-tasks".to_owned(),
1159                "tasks".to_owned(),
1160                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1161                None,
1162            )
1163            .expect("subscribe");
1164        client.drain_sync_intents();
1165
1166        client
1167            .run_log_epoch_reset("epoch-2")
1168            .expect("reset partition log epoch");
1169
1170        assert!(matches!(
1171            client.drain_sync_intents().as_slice(),
1172            [SyncIntent::Interactive]
1173        ));
1174    }
1175
1176    #[derive(Default)]
1177    struct CountingRealtimeTransport {
1178        connects: usize,
1179        closes: usize,
1180        messages: Vec<String>,
1181        blob_uploads: Vec<(String, Vec<u8>)>,
1182        blob_download: Option<Vec<u8>>,
1183        sync_calls: usize,
1184        realtime_sync_calls: usize,
1185        refuse_connect: bool,
1186    }
1187
1188    impl Transport for CountingRealtimeTransport {
1189        fn blob_upload(
1190            &mut self,
1191            blob_id: &str,
1192            bytes: &[u8],
1193            _media_type: Option<&str>,
1194        ) -> Result<(), TransportError> {
1195            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1196            Ok(())
1197        }
1198
1199        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1200            self.blob_download
1201                .clone()
1202                .map(BlobDownload::Bytes)
1203                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1204        }
1205
1206        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1207            self.sync_calls += 1;
1208            Err(TransportError::new("sync.transport_failed", "offline"))
1209        }
1210
1211        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1212            self.realtime_sync_calls += 1;
1213            Err(TransportError::new("sync.transport_failed", "offline"))
1214        }
1215
1216        fn download_segment(
1217            &mut self,
1218            _request: &SegmentRequest,
1219            _on_progress: &mut dyn FnMut(u64),
1220        ) -> Result<Vec<u8>, TransportError> {
1221            Err(TransportError::new("sync.transport_failed", "offline"))
1222        }
1223
1224        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1225            self.connects += 1;
1226            if self.refuse_connect {
1227                return Err(TransportError::new("transport.failed", "refused"));
1228            }
1229            Ok(())
1230        }
1231
1232        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1233            self.messages.push(text.to_owned());
1234            Ok(())
1235        }
1236
1237        fn realtime_close(&mut self) -> Result<(), TransportError> {
1238            self.closes += 1;
1239            Ok(())
1240        }
1241    }
1242
1243    #[test]
1244    fn blob_results_own_downloaded_bytes() {
1245        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1246            {"name":"id","type":"string","nullable":false},
1247            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1248        let bytes = b"owned typed result".to_vec();
1249        let blob_id = blob_id_for(&bytes);
1250        let mut transport = CountingRealtimeTransport {
1251            blob_download: Some(bytes.clone()),
1252            ..Default::default()
1253        };
1254        let result = {
1255            let mut client =
1256                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1257                    .unwrap();
1258            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1259            assert_eq!(result.blob_id, blob_id);
1260            assert_eq!(result.byte_length, bytes.len() as i64);
1261            assert_eq!(result.bytes, bytes);
1262            assert_eq!(result.media_type, None);
1263            assert_eq!(
1264                client
1265                    .conn
1266                    .query_row(
1267                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1268                        rusqlite::params![blob_id],
1269                        |row| row.get::<_, i64>(0),
1270                    )
1271                    .unwrap(),
1272                0
1273            );
1274            result
1275        };
1276        assert_eq!(result.bytes, bytes);
1277
1278        let mut client =
1279            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1280        assert_eq!(
1281            client
1282                .fetch_blob_bytes(&mut transport, "not json")
1283                .unwrap_err(),
1284            (
1285                "client.failed".to_owned(),
1286                "blob ref is not JSON".to_owned()
1287            )
1288        );
1289    }
1290
1291    #[test]
1292    fn visible_blob_references_survive_cache_trimming() {
1293        let schema = json!({"version":1,"tables":[
1294            {"name":"attachments","primaryKey":"id","columns":[
1295                {"name":"id","type":"string","nullable":false},
1296                {"name":"body","type":"blob_ref","nullable":true},
1297                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1298            {"name":"avatars","primaryKey":"id","columns":[
1299                {"name":"id","type":"string","nullable":false},
1300                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1301        ]});
1302        let bytes = b"referenced above cap".to_vec();
1303        let blob_id = blob_id_for(&bytes);
1304        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1305        let mut client = SyncClient::new(
1306            "visible-blob-refs".to_owned(),
1307            &schema,
1308            ClientLimits {
1309                blob_cache_max_bytes: Some(1),
1310                ..Default::default()
1311            },
1312        )
1313        .unwrap();
1314        client.conn.execute(
1315            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1316            rusqlite::params![reference, reference, reference, reference],
1317        ).unwrap();
1318        client
1319            .conn
1320            .execute(
1321                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1322                rusqlite::params![reference],
1323            )
1324            .unwrap();
1325        client.conn.execute(
1326            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1327            [],
1328        ).unwrap();
1329        let mut transport = CountingRealtimeTransport {
1330            blob_download: Some(bytes.clone()),
1331            ..Default::default()
1332        };
1333
1334        assert_eq!(
1335            client
1336                .fetch_blob_bytes(&mut transport, &blob_id)
1337                .unwrap()
1338                .bytes,
1339            bytes
1340        );
1341        let rows = client
1342            .conn
1343            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1344            .unwrap()
1345            .query_map([], |row| row.get::<_, String>(0))
1346            .unwrap()
1347            .collect::<Result<Vec<_>, _>>()
1348            .unwrap();
1349        assert_eq!(rows, vec![blob_id]);
1350    }
1351
1352    #[test]
1353    fn realtime_connection_ownership_is_idempotent() {
1354        let mut client = client();
1355        let mut transport = CountingRealtimeTransport {
1356            connects: 0,
1357            closes: 0,
1358            ..Default::default()
1359        };
1360        client
1361            .connect_realtime(&mut transport)
1362            .expect("first connect");
1363        client
1364            .connect_realtime(&mut transport)
1365            .expect("idempotent connect");
1366        assert_eq!(transport.connects, 1);
1367        client.disconnect_realtime(&mut transport);
1368        client.disconnect_realtime(&mut transport);
1369        assert_eq!(transport.closes, 1);
1370        client
1371            .connect_realtime(&mut transport)
1372            .expect("deliberate reconnect");
1373        assert_eq!(transport.connects, 2);
1374    }
1375
1376    #[test]
1377    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1378        let mut client = client();
1379        client
1380            .set_realtime_policy(RealtimePolicy::Required)
1381            .expect("set policy");
1382        let mut transport = CountingRealtimeTransport::default();
1383        match client.sync(&mut transport) {
1384            SyncOutcome::RealtimeUnavailable {
1385                state,
1386                reason_code,
1387                retry_delay_ms,
1388            } => {
1389                assert_eq!(state, RealtimeState::Disconnected);
1390                assert_eq!(reason_code, None);
1391                assert_eq!(retry_delay_ms, 250);
1392            }
1393            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1394        }
1395        assert_eq!(transport.sync_calls, 0);
1396        assert!(matches!(
1397            client.drain_sync_intents().as_slice(),
1398            [SyncIntent::Background { delay_ms: 250 }]
1399        ));
1400        let snapshot = client
1401            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1402            .expect("diagnostics");
1403        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1404        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1405        assert_eq!(snapshot.host.realtime_reason_code, None);
1406        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1407        assert_eq!(
1408            snapshot
1409                .last_round
1410                .as_ref()
1411                .and_then(|round| round.error_code.clone()),
1412            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1413        );
1414    }
1415
1416    #[test]
1417    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1418        let mut client = client();
1419        client
1420            .set_realtime_policy(RealtimePolicy::Required)
1421            .expect("set policy");
1422        let mut transport = CountingRealtimeTransport {
1423            refuse_connect: true,
1424            ..Default::default()
1425        };
1426        assert!(client.connect_realtime(&mut transport).is_err());
1427        assert_eq!(client.realtime_state(), RealtimeState::Refused);
1428        let snapshot = client
1429            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1430            .expect("diagnostics");
1431        assert_eq!(
1432            snapshot.host.realtime_reason_code.as_deref(),
1433            Some("transport.failed")
1434        );
1435        match client.sync(&mut transport) {
1436            SyncOutcome::RealtimeUnavailable {
1437                state, reason_code, ..
1438            } => {
1439                assert_eq!(state, RealtimeState::Refused);
1440                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1441            }
1442            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1443        }
1444        assert_eq!(transport.sync_calls, 0);
1445        assert_eq!(transport.realtime_sync_calls, 0);
1446    }
1447
1448    #[test]
1449    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1450        let mut client = client();
1451        client
1452            .set_realtime_policy(RealtimePolicy::Required)
1453            .expect("set policy");
1454        let mut transport = CountingRealtimeTransport::default();
1455        client.connect_realtime(&mut transport).expect("connect");
1456        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1457        assert!(
1458            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1459            "a policy change cannot silently re-route a connected socket"
1460        );
1461        assert!(matches!(
1462            client.sync(&mut transport),
1463            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1464        ));
1465        assert_eq!(client.realtime_state(), RealtimeState::Lost);
1466        assert_eq!(transport.realtime_sync_calls, 1);
1467        assert_eq!(transport.sync_calls, 0);
1468        let snapshot = client
1469            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1470            .expect("diagnostics");
1471        assert_eq!(
1472            snapshot.host.realtime_reason_code.as_deref(),
1473            Some("sync.transport_failed")
1474        );
1475        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1476        match client.sync(&mut transport) {
1477            SyncOutcome::RealtimeUnavailable {
1478                state, reason_code, ..
1479            } => {
1480                assert_eq!(state, RealtimeState::Lost);
1481                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1482            }
1483            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1484        }
1485        assert_eq!(transport.sync_calls, 0);
1486        client.disconnect_realtime(&mut transport);
1487        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1488        assert_eq!(transport.closes, 1);
1489        client.connect_realtime(&mut transport).expect("reconnect");
1490        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1491        assert_eq!(transport.connects, 2);
1492    }
1493
1494    #[test]
1495    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1496        let mut client = client();
1497        client
1498            .set_realtime_policy(RealtimePolicy::Off)
1499            .expect("set policy");
1500        let mut transport = CountingRealtimeTransport::default();
1501        assert!(client.connect_realtime(&mut transport).is_err());
1502        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1503        assert!(matches!(
1504            client.sync(&mut transport),
1505            SyncOutcome::Failed { .. }
1506        ));
1507        assert_eq!(transport.sync_calls, 1);
1508        assert_eq!(transport.realtime_sync_calls, 0);
1509        let snapshot = client
1510            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1511            .expect("diagnostics");
1512        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1513        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1514    }
1515
1516    #[test]
1517    fn optional_policy_keeps_the_http_round() {
1518        let mut client = client();
1519        let mut transport = CountingRealtimeTransport::default();
1520        assert!(matches!(
1521            client.sync(&mut transport),
1522            SyncOutcome::Failed { .. }
1523        ));
1524        assert_eq!(transport.sync_calls, 1);
1525        assert_eq!(transport.realtime_sync_calls, 0);
1526        let snapshot = client
1527            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1528            .expect("diagnostics");
1529        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1530        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1531    }
1532
1533    #[test]
1534    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1535        let mut client = client();
1536        client
1537            .subscribe(
1538                "repair-tasks".to_owned(),
1539                "tasks".to_owned(),
1540                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1541                None,
1542            )
1543            .expect("subscribe");
1544        {
1545            let sub = client
1546                .subs
1547                .iter_mut()
1548                .find(|sub| sub.id == "repair-tasks")
1549                .expect("subscription");
1550            sub.cursor = 42;
1551            sub.synced_once = true;
1552            let persisted = sub.clone();
1553            client.persist_sub(&persisted).unwrap();
1554        }
1555        for table in ["_syncular_base_tasks", "tasks"] {
1556            client
1557                .conn
1558                .execute(
1559                    &format!(
1560                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1561                    ),
1562                    rusqlite::params!["server-row", "p1"],
1563                )
1564                .expect("seed server row");
1565        }
1566        let pending = client
1567            .mutate(vec![Mutation::Upsert {
1568                table: "tasks".to_owned(),
1569                values: Map::from_iter([
1570                    ("id".to_owned(), Value::from("offline-row")),
1571                    ("project_id".to_owned(), Value::from("p1")),
1572                ]),
1573                base_version: None,
1574            }])
1575            .expect("queue offline work");
1576        client.drain_change_batches();
1577        client.drain_sync_intents();
1578
1579        assert_eq!(
1580            client
1581                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1582                    rebootstrap_id: "support-case-001".to_owned(),
1583                })
1584                .expect("rebootstrap"),
1585            LocalDataRebootstrapResult {
1586                already_applied: false,
1587                retained_commits: 1,
1588                reset_subscriptions: 1,
1589            }
1590        );
1591        let visible_ids = client
1592            .conn
1593            .prepare("SELECT id FROM tasks ORDER BY id")
1594            .expect("prepare visible ids")
1595            .query_map([], |row| row.get::<_, String>(0))
1596            .expect("query visible ids")
1597            .collect::<Result<Vec<_>, _>>()
1598            .expect("collect visible ids");
1599        assert_eq!(visible_ids, vec!["offline-row"]);
1600        assert_eq!(client.pending_commit_ids(), vec![pending]);
1601        assert_eq!(
1602            client
1603                .subscription_state("repair-tasks")
1604                .expect("subscription")
1605                .cursor,
1606            -1
1607        );
1608        assert!(client.upgrading());
1609        assert!(client.sync_needed());
1610        assert!(matches!(
1611            client.drain_sync_intents().as_slice(),
1612            [SyncIntent::Interactive]
1613        ));
1614        assert_eq!(client.drain_change_batches().len(), 1);
1615
1616        assert_eq!(
1617            client
1618                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1619                    rebootstrap_id: "support-case-001".to_owned(),
1620                })
1621                .expect("idempotent retry"),
1622            LocalDataRebootstrapResult {
1623                already_applied: true,
1624                retained_commits: 1,
1625                reset_subscriptions: 1,
1626            }
1627        );
1628        assert!(client.drain_change_batches().is_empty());
1629    }
1630
1631    #[test]
1632    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1633        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1634        assert_eq!(
1635            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1636            (3, 4)
1637        );
1638        assert_eq!(
1639            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1640            (0, 0)
1641        );
1642        for malformed in [
1643            "",
1644            "{}",
1645            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1646            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1647            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1648        ] {
1649            assert_eq!(
1650                decode_local_rebootstrap_receipt(malformed)
1651                    .expect_err("malformed receipt must fail"),
1652                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1653            );
1654        }
1655    }
1656
1657    #[test]
1658    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1659        let path = std::env::temp_dir().join(format!(
1660            "syncular-rebootstrap-receipt-{}.db",
1661            uuid::Uuid::new_v4()
1662        ));
1663        let schema = json!({
1664            "version": 1,
1665            "tables": [{
1666                "name": "tasks",
1667                "primaryKey": "id",
1668                "columns": [
1669                    { "name": "id", "type": "string", "nullable": false },
1670                    { "name": "project_id", "type": "string", "nullable": false }
1671                ],
1672                "scopes": [{ "pattern": "project:{project_id}" }]
1673            }]
1674        });
1675        let path_string = path.to_str().expect("UTF-8 temp path");
1676
1677        {
1678            let mut first = SyncClient::open_path(
1679                "repair-restart-client".to_owned(),
1680                &schema,
1681                ClientLimits::default(),
1682                path_string,
1683            )
1684            .expect("first open");
1685            first
1686                .subscribe(
1687                    "repair-tasks".to_owned(),
1688                    "tasks".to_owned(),
1689                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1690                    None,
1691                )
1692                .expect("subscribe");
1693            first
1694                .mutate(vec![Mutation::Upsert {
1695                    table: "tasks".to_owned(),
1696                    values: Map::from_iter([
1697                        ("id".to_owned(), Value::from("offline-row")),
1698                        ("project_id".to_owned(), Value::from("p1")),
1699                    ]),
1700                    base_version: None,
1701                }])
1702                .expect("queue offline work");
1703            assert_eq!(
1704                first
1705                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1706                        rebootstrap_id: "restart-receipt".to_owned(),
1707                    })
1708                    .expect("first rebootstrap"),
1709                LocalDataRebootstrapResult {
1710                    already_applied: false,
1711                    retained_commits: 1,
1712                    reset_subscriptions: 1,
1713                }
1714            );
1715        }
1716
1717        let mut reopened = SyncClient::open_path(
1718            "repair-restart-client".to_owned(),
1719            &schema,
1720            ClientLimits::default(),
1721            path_string,
1722        )
1723        .expect("reopen");
1724        reopened.drain_change_batches();
1725        reopened.drain_sync_intents();
1726        assert_eq!(
1727            reopened
1728                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1729                    rebootstrap_id: "restart-receipt".to_owned(),
1730                })
1731                .expect("receipt replay"),
1732            LocalDataRebootstrapResult {
1733                already_applied: true,
1734                retained_commits: 1,
1735                reset_subscriptions: 1,
1736            }
1737        );
1738        assert!(reopened.drain_change_batches().is_empty());
1739        assert!(reopened.drain_sync_intents().is_empty());
1740        drop(reopened);
1741        std::fs::remove_file(path).expect("remove temp database");
1742    }
1743
1744    #[test]
1745    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1746        let mut malformed = client();
1747        malformed
1748            .subscribe(
1749                "repair-tasks".to_owned(),
1750                "tasks".to_owned(),
1751                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1752                None,
1753            )
1754            .expect("subscribe");
1755        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1756        malformed.drain_change_batches();
1757        malformed.drain_sync_intents();
1758        let malformed_error = malformed
1759            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1760                rebootstrap_id: "malformed".to_owned(),
1761            })
1762            .expect_err("malformed receipt must fail closed");
1763        assert_eq!(
1764            malformed_error,
1765            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1766        );
1767        assert!(!malformed.upgrading());
1768        assert_eq!(
1769            malformed
1770                .subscription_state("repair-tasks")
1771                .expect("unchanged subscription")
1772                .cursor,
1773            -1
1774        );
1775        assert!(malformed.drain_change_batches().is_empty());
1776        assert!(malformed.drain_sync_intents().is_empty());
1777
1778        let mut unreadable = client();
1779        unreadable
1780            .conn
1781            .execute(
1782                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1783                rusqlite::params!["server-row", "p1"],
1784            )
1785            .expect("seed visible row");
1786        unreadable
1787            .conn
1788            .execute("DROP TABLE _syncular_meta", [])
1789            .expect("break marker storage");
1790        let unreadable_error = unreadable
1791            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1792                rebootstrap_id: "unreadable".to_owned(),
1793            })
1794            .expect_err("unreadable marker storage must fail closed");
1795        assert_eq!(
1796            unreadable_error,
1797            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1798        );
1799        let visible_rows = unreadable
1800            .conn
1801            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1802            .expect("visible projection remains");
1803        assert_eq!(visible_rows, 1);
1804        assert!(!unreadable.upgrading());
1805        assert!(unreadable.drain_change_batches().is_empty());
1806        assert!(unreadable.drain_sync_intents().is_empty());
1807    }
1808
1809    #[test]
1810    fn local_rebootstrap_cannot_bypass_schema_floor() {
1811        let mut client = client();
1812        client.set_schema_floor(Some(SchemaFloor {
1813            required_schema_version: Some(2),
1814            latest_schema_version: Some(2),
1815        }));
1816        let error = client
1817            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1818                rebootstrap_id: "blocked-floor".to_owned(),
1819            })
1820            .expect_err("schema floor must block repair");
1821        assert!(error.contains("cannot bypass an active schema-floor stop"));
1822    }
1823
1824    #[test]
1825    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1826        let mut client = client();
1827        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1828        const COMMIT_COUNT: usize = 32;
1829
1830        for index in 0..COMMIT_COUNT {
1831            client
1832                .mutate(vec![Mutation::Upsert {
1833                    table: "tasks".to_owned(),
1834                    values: Map::from_iter([
1835                        ("id".to_owned(), Value::from(format!("task-{index}"))),
1836                        ("project_id".to_owned(), Value::from("project-1")),
1837                    ]),
1838                    base_version: None,
1839                }])
1840                .expect("queue commit");
1841        }
1842
1843        let (_, request_meta) = client.build_request(false);
1844        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1845        let mut frames = vec![Frame::RespHeader {
1846            required_schema_version: None,
1847            latest_schema_version: None,
1848            log_epoch: Some("epoch-1".to_owned()),
1849            reset_required: Some(false),
1850        }];
1851        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1852            |(index, client_commit_id)| Frame::PushResult {
1853                client_commit_id: client_commit_id.clone(),
1854                status: PushStatus::Applied,
1855                commit_seq: Some(index as i64 + 1),
1856                results: vec![OpResult::Applied { op_index: 0 }],
1857            },
1858        ));
1859        let response = Message {
1860            wire_version: WIRE_VERSION,
1861            msg_kind: MsgKind::Response,
1862            frames,
1863        };
1864        let mut transport =
1865            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1866
1867        client.overlay_rebuild_count.set(0);
1868        client.outcome_prune_count.set(0);
1869        let outcome = client.process_response(&mut transport, response, &request_meta);
1870        assert!(matches!(outcome, SyncOutcome::Ok(_)));
1871        assert!(client.pending_commit_ids().is_empty());
1872        assert_eq!(
1873            client.overlay_rebuild_count.get(),
1874            1,
1875            "one response must reconcile its acknowledged commits with one overlay rebuild"
1876        );
1877        assert_eq!(
1878            client.outcome_prune_count.get(),
1879            1,
1880            "one response must enforce outcome retention once"
1881        );
1882    }
1883
1884    #[test]
1885    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1886        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1887            "columns": [
1888                {"name":"id", "type":"string", "nullable":false},
1889                {"name":"project_id", "type":"string", "nullable":false},
1890                {"name":"title", "type":"string", "nullable":false}
1891            ], "scopes": [{"pattern":"project:{project_id}"}],
1892            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1893            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1894        }] });
1895        let mut incremental =
1896            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1897        let mut reference =
1898            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1899        let tables = vec!["tasks".to_owned()];
1900        for index in 0..32 {
1901            let id = format!("task-{}", index % 8);
1902            let values = Map::from_iter([
1903                ("id".to_owned(), Value::from(id.clone())),
1904                (
1905                    "project_id".to_owned(),
1906                    Value::from(format!("p{}", index % 2)),
1907                ),
1908                ("title".to_owned(), Value::from(format!("needle{index}"))),
1909            ]);
1910            let payload = encode_row_json(
1911                incremental.schema.table("tasks").unwrap(),
1912                &id,
1913                &values,
1914                &incremental.encryption,
1915            )
1916            .unwrap();
1917            let change = ssp2::model::Change {
1918                table_index: 0,
1919                row_id: id,
1920                op: if index % 5 == 0 {
1921                    Op::Delete
1922                } else {
1923                    Op::Upsert
1924                },
1925                row_version: Some(index + 1),
1926                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1927                row: Some(payload),
1928            };
1929            reference.overlay_dirty.set(true);
1930            for instance in [&mut incremental, &mut reference] {
1931                instance.begin_observation("mirror_test").unwrap();
1932                let mut batch = ChangeAccumulator::default();
1933                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1934                instance
1935                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
1936                    .unwrap();
1937                instance.rebuild_overlay_if_dirty();
1938                instance.finish_observation("mirror_test", batch).unwrap();
1939            }
1940            for sql in [
1941                "SELECT * FROM tasks ORDER BY id",
1942                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1943            ] {
1944                assert_eq!(
1945                    incremental.query(sql, &[]).unwrap(),
1946                    reference.query(sql, &[]).unwrap()
1947                );
1948            }
1949            assert_eq!(incremental.local_revision(), reference.local_revision());
1950            assert_eq!(
1951                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1952                serde_json::to_value(reference.drain_change_batches()).unwrap()
1953            );
1954        }
1955        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1956        assert_eq!(reference.overlay_rebuild_count.get(), 32);
1957    }
1958
1959    #[test]
1960    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1961        let cases = [
1962            (
1963                "string",
1964                vec![
1965                    json!("1"),
1966                    json!("01"),
1967                    json!(""),
1968                    json!("nul\u{0}id"),
1969                    json!("é"),
1970                ],
1971            ),
1972            (
1973                "integer",
1974                vec![
1975                    json!(i64::MIN),
1976                    json!(-1),
1977                    json!(0),
1978                    json!(1),
1979                    json!(i64::MAX),
1980                ],
1981            ),
1982            ("boolean", vec![json!(false), json!(true)]),
1983            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1984        ];
1985        for (kind, ids) in cases {
1986            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1987                "columns":[{"name":"id","type":kind,"nullable":false},
1988                    {"name":"project_id","type":"string","nullable":false}],
1989                "scopes":[{"pattern":"project:{project_id}"}]}]});
1990            let mut client =
1991                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1992            for (index, id) in ids.iter().enumerate() {
1993                let table = client.schema.table("tasks").unwrap();
1994                client
1995                    .write_base_row(
1996                        "tasks",
1997                        &vec![
1998                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1999                            Some(ColumnValue::String(format!("p{index}"))),
2000                        ],
2001                        1,
2002                    )
2003                    .unwrap();
2004            }
2005            client.rebuild_overlay_if_dirty();
2006            for base in [true, false] {
2007                let full = if base {
2008                    base_table("tasks")
2009                } else {
2010                    visible_table("tasks")
2011                };
2012                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2013                for row_id in [
2014                    "1",
2015                    "01",
2016                    "1.0",
2017                    "1.0000000000000002",
2018                    "+1",
2019                    "1e0",
2020                    " 1",
2021                    "-1",
2022                    "0",
2023                    "9223372036854775807",
2024                    "-9223372036854775808",
2025                    "true",
2026                    "false",
2027                    "",
2028                    "nul\u{0}id",
2029                    "é",
2030                    "null",
2031                    "{\"a\":1}",
2032                ] {
2033                    let old_sql =
2034                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2035                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2036                    let old = client
2037                        .conn
2038                        .prepare(&old_sql)
2039                        .unwrap()
2040                        .query_map([row_id], |row| row.get::<_, String>(0))
2041                        .unwrap()
2042                        .collect::<Result<Vec<_>, _>>()
2043                        .unwrap();
2044                    let new = client
2045                        .conn
2046                        .prepare_cached(&new_sql)
2047                        .unwrap()
2048                        .query_map([row_id], |row| row.get::<_, String>(0))
2049                        .unwrap()
2050                        .collect::<Result<Vec<_>, _>>()
2051                        .unwrap();
2052                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2053                    let mut batch = ChangeAccumulator::default();
2054                    assert_eq!(
2055                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
2056                        !old.is_empty()
2057                    );
2058                    let expected = old.first().map(|scope| {
2059                        BTreeMap::from([(
2060                            "tasks".to_owned(),
2061                            Some(BTreeSet::from([format!("project:{scope}")])),
2062                        )])
2063                    });
2064                    assert_eq!(batch.tables, expected.unwrap_or_default());
2065                    client
2066                        .conn
2067                        .execute_batch("SAVEPOINT lookup_delete")
2068                        .unwrap();
2069                    if base {
2070                        client.delete_base_row("tasks", row_id).unwrap();
2071                    } else {
2072                        client.apply_outbox_ops(&[OutboxOp {
2073                            upsert: false,
2074                            table: "tasks".into(),
2075                            row_id: row_id.into(),
2076                            base_version: None,
2077                            values: None,
2078                        }]);
2079                    }
2080                    let remaining: i64 = client
2081                        .conn
2082                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2083                            row.get(0)
2084                        })
2085                        .unwrap();
2086                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2087                    client
2088                        .conn
2089                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2090                        .unwrap();
2091                    client.overlay_dirty.set(false);
2092                }
2093                for (predicate, expected) in [
2094                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2095                    (predicate, "SEARCH"),
2096                ] {
2097                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2098                    let details = client
2099                        .conn
2100                        .prepare(&sql)
2101                        .unwrap()
2102                        .query_map(["1"], |row| row.get::<_, String>(3))
2103                        .unwrap()
2104                        .collect::<Result<Vec<_>, _>>()
2105                        .unwrap();
2106                    assert!(
2107                        details.iter().any(|detail| detail.contains(expected)),
2108                        "{kind}: {details:?}"
2109                    );
2110                }
2111            }
2112        }
2113    }
2114
2115    #[test]
2116    fn primary_keys_reject_every_ineligible_column_type() {
2117        // §2.4: a `rowId` is a string, so a primary key type whose string form
2118        // differs per renderer, or cannot be reproduced by local storage
2119        // comparison, is a schema error rather than a silent lookup miss.
2120        for ty in ["float", "bytes", "crdt", "blob_ref"] {
2121            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2122                "columns":[{"name":"id","type":ty,"nullable":false},
2123                    {"name":"project_id","type":"string","nullable":false}],
2124                "scopes":[{"pattern":"project:{project_id}"}]}]});
2125            let error =
2126                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2127                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2128                    Err(error) => error,
2129                };
2130            assert!(
2131                error.contains("has an ineligible column type (§2.4)"),
2132                "{ty}: {error}"
2133            );
2134        }
2135        for ty in ["string", "integer", "boolean", "json"] {
2136            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2137                "columns":[{"name":"id","type":ty,"nullable":false},
2138                    {"name":"project_id","type":"string","nullable":false}],
2139                "scopes":[{"pattern":"project:{project_id}"}]}]});
2140            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2141                .unwrap_or_else(|error| panic!("{ty}: {error}"));
2142        }
2143    }
2144
2145    #[test]
2146    fn pending_row_reconciliation_matches_full_fifo_replay() {
2147        for unique in [false, true] {
2148            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2149                "name": name, "primaryKey": "id", "columns": [
2150                    {"name":"id", "type":"string", "nullable":false},
2151                    {"name":"project_id", "type":"string", "nullable":false},
2152                    {"name":"title", "type":"string", "nullable":false}],
2153                "scopes": [{"pattern":"project:{project_id}"}],
2154                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2155                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2156            })))});
2157            let mut incremental =
2158                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2159            let mut reference =
2160                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2161            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2162            for instance in [&mut incremental, &mut reference] {
2163                for table in &tables {
2164                    for index in 0..12 {
2165                        instance
2166                            .write_base_row(
2167                                table,
2168                                &vec![
2169                                    Some(ColumnValue::String(format!("row{index}"))),
2170                                    Some(ColumnValue::String("p0".into())),
2171                                    Some(ColumnValue::String(format!("base{index}"))),
2172                                ],
2173                                1,
2174                            )
2175                            .unwrap();
2176                    }
2177                }
2178                instance.rebuild_overlay_if_dirty();
2179            }
2180            for index in 0..80 {
2181                let mutations = (0..3)
2182                    .map(|offset| {
2183                        let table = tables[(index + offset) % 2].clone();
2184                        let id = format!("row{}", (index + offset) % 16);
2185                        if (index + offset) % 7 == 0 {
2186                            Mutation::Delete {
2187                                table,
2188                                row_id: id,
2189                                base_version: None,
2190                            }
2191                        } else {
2192                            Mutation::Upsert {
2193                                table,
2194                                values: Map::from_iter([
2195                                    ("id".into(), Value::from(id)),
2196                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
2197                                    (
2198                                        "title".into(),
2199                                        Value::from(format!("needle{}", (index + offset) % 16)),
2200                                    ),
2201                                ]),
2202                                base_version: None,
2203                            }
2204                        }
2205                    })
2206                    .collect::<Vec<_>>();
2207                for instance in [&mut incremental, &mut reference] {
2208                    instance.mutate(mutations.clone()).unwrap();
2209                }
2210                // Most frames touch only the table without unique constraints.
2211                // Mixed frames must retain full replay for both tables.
2212                let changes = (0..3)
2213                    .map(|offset| {
2214                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2215                        let id = format!("row{}", (index + offset) % 16);
2216                        let values = Map::from_iter([
2217                            ("id".into(), Value::from(id.clone())),
2218                            (
2219                                "project_id".into(),
2220                                Value::from(format!("p{}", (index + 1) % 3)),
2221                            ),
2222                            (
2223                                "title".into(),
2224                                Value::from(format!("remote{index}_{offset}")),
2225                            ),
2226                        ]);
2227                        ssp2::model::Change {
2228                            table_index: table_index as u16,
2229                            row_id: id.clone(),
2230                            op: if (index + offset) % 5 == 0 {
2231                                Op::Delete
2232                            } else {
2233                                Op::Upsert
2234                            },
2235                            row_version: Some(index as i64 + 2),
2236                            scopes: vec![],
2237                            row: Some(
2238                                encode_row_json(
2239                                    incremental.schema.table(&tables[table_index]).unwrap(),
2240                                    &id,
2241                                    &values,
2242                                    &incremental.encryption,
2243                                )
2244                                .unwrap(),
2245                            ),
2246                        }
2247                    })
2248                    .collect::<Vec<_>>();
2249                reference.overlay_dirty.set(true);
2250                incremental.apply_commit_frame(&tables, &changes).unwrap();
2251                reference.apply_commit_frame(&tables, &changes).unwrap();
2252                for table in &tables {
2253                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2254                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2255                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2256                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2257                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2258                    }
2259                }
2260                assert_eq!(incremental.local_revision(), reference.local_revision());
2261                assert_eq!(
2262                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2263                    serde_json::to_value(reference.drain_change_batches()).unwrap()
2264                );
2265            }
2266            assert_eq!(
2267                incremental.overlay_rebuild_count.get(),
2268                if unique { 81 } else { 21 }
2269            );
2270            assert_eq!(reference.overlay_rebuild_count.get(), 81);
2271        }
2272    }
2273
2274    #[test]
2275    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2276        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2277            "columns":[{"name":"id", "type":"string", "nullable":false},
2278                {"name":"title", "type":"string", "nullable":false}],
2279            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2280        let mut client =
2281            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2282        for (id, title) in [("a", "original"), ("b", "occupied")] {
2283            client
2284                .write_base_row(
2285                    "tasks",
2286                    &vec![
2287                        Some(ColumnValue::String(id.into())),
2288                        Some(ColumnValue::String(title.into())),
2289                    ],
2290                    1,
2291                )
2292                .unwrap();
2293        }
2294        client.rebuild_overlay_if_dirty();
2295        let pending = client
2296            .mutate(vec![Mutation::Upsert {
2297                table: "tasks".into(),
2298                values: Map::from_iter([
2299                    ("id".into(), Value::from("a")),
2300                    ("title".into(), Value::from("available")),
2301                ]),
2302                base_version: None,
2303            }])
2304            .unwrap();
2305        assert_eq!(
2306            client
2307                .conn
2308                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2309                    .get::<_, String>(
2310                    0
2311                ))
2312                .unwrap(),
2313            "available"
2314        );
2315        for (title, expected) in [("available", "original"), ("occupied", "available")] {
2316            let change = ssp2::model::Change {
2317                table_index: 0,
2318                row_id: "b".into(),
2319                op: Op::Upsert,
2320                row_version: Some(2),
2321                scopes: vec![],
2322                row: Some(
2323                    encode_row_json(
2324                        client.schema.table("tasks").unwrap(),
2325                        "b",
2326                        &Map::from_iter([
2327                            ("id".into(), Value::from("b")),
2328                            ("title".into(), Value::from(title)),
2329                        ]),
2330                        &client.encryption,
2331                    )
2332                    .unwrap(),
2333                ),
2334            };
2335            client
2336                .apply_commit_frame(&["tasks".into()], &[change])
2337                .unwrap();
2338            assert_eq!(
2339                client
2340                    .conn
2341                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2342                        .get::<_, String>(
2343                        0
2344                    ))
2345                    .unwrap(),
2346                expected
2347            );
2348            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2349        }
2350        assert_eq!(client.overlay_rebuild_count.get(), 3);
2351    }
2352
2353    #[test]
2354    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2355        for failure in ["visible", "revision", "commit"] {
2356            let mut client = client();
2357            let id = client
2358                .mutate(vec![Mutation::Upsert {
2359                    table: "tasks".into(),
2360                    values: Map::from_iter([
2361                        ("id".into(), Value::from("t1")),
2362                        ("project_id".into(), Value::from("pending")),
2363                    ]),
2364                    base_version: None,
2365                }])
2366                .unwrap();
2367            client.drain_change_batches();
2368            let revision = client.local_revision();
2369            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2370            let trigger = match failure {
2371                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2372                "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"),
2373                _ => "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(),
2374            };
2375            client.conn.execute_batch(&trigger).unwrap();
2376            let change = ssp2::model::Change {
2377                table_index: 0,
2378                row_id: "t1".into(),
2379                op: Op::Upsert,
2380                row_version: Some(2),
2381                scopes: vec![],
2382                row: Some(
2383                    encode_row_json(
2384                        client.schema.table("tasks").unwrap(),
2385                        "t1",
2386                        &Map::from_iter([
2387                            ("id".into(), Value::from("t1")),
2388                            ("project_id".into(), Value::from("remote")),
2389                        ]),
2390                        &client.encryption,
2391                    )
2392                    .unwrap(),
2393                ),
2394            };
2395            assert!(client
2396                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2397                .is_err());
2398            assert!(client.conn.is_autocommit());
2399            assert!(!client.overlay_dirty.get());
2400            assert_eq!(client.local_revision(), revision);
2401            assert!(client.drain_change_batches().is_empty());
2402            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2403            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2404            assert!(client
2405                .query("SELECT * FROM _syncular_base_tasks", &[])
2406                .unwrap()
2407                .is_empty());
2408            client
2409                .conn
2410                .execute_batch("DROP TRIGGER fail_pending")
2411                .unwrap();
2412            client
2413                .apply_commit_frame(&["tasks".into()], &[change])
2414                .unwrap();
2415            assert_eq!(client.local_revision(), revision + 1);
2416            assert_eq!(client.pending_commit_ids(), vec![id]);
2417            // The overlay keeps the pending write visible; the row now carries
2418            // the server row's version it is layered on (§7.1).
2419            let mut expected = before[0].clone();
2420            expected.insert("_syncular_version".to_owned(), Value::from(2));
2421            assert_eq!(
2422                client.query("SELECT * FROM tasks", &[]).unwrap(),
2423                vec![expected]
2424            );
2425            assert_eq!(
2426                client
2427                    .conn
2428                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2429                        .get::<_, String>(0))
2430                    .unwrap(),
2431                "remote"
2432            );
2433        }
2434    }
2435
2436    #[test]
2437    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2438        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2439            "columns": [
2440                {"name":"id", "type":"string", "nullable":false},
2441                {"name":"project_id", "type":"string", "nullable":false},
2442                {"name":"title", "type":"string", "nullable":false}],
2443            "scopes": [{"pattern":"project:{project_id}"}],
2444            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2445            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2446        let mut incremental =
2447            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2448        let mut reference =
2449            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2450        for instance in [&mut incremental, &mut reference] {
2451            for index in 0..16 {
2452                instance
2453                    .write_base_row(
2454                        "tasks",
2455                        &vec![
2456                            Some(ColumnValue::String(format!("task{index}"))),
2457                            Some(ColumnValue::String("p0".to_owned())),
2458                            Some(ColumnValue::String(format!("base{index}"))),
2459                        ],
2460                        1,
2461                    )
2462                    .unwrap();
2463            }
2464            instance.rebuild_overlay_if_dirty();
2465            instance.overlay_rebuild_count.set(0);
2466        }
2467        for index in 0..100 {
2468            let mutations = (0..1 + index % 4)
2469                .map(|offset| {
2470                    let id = format!("task{}", (index + offset) % 24);
2471                    if (index + offset) % 7 == 0 {
2472                        Mutation::Delete {
2473                            table: "tasks".to_owned(),
2474                            row_id: id,
2475                            base_version: None,
2476                        }
2477                    } else {
2478                        Mutation::Upsert {
2479                            table: "tasks".to_owned(),
2480                            values: Map::from_iter([
2481                                ("id".to_owned(), Value::from(id)),
2482                                (
2483                                    "project_id".to_owned(),
2484                                    Value::from(format!("p{}", index % 2)),
2485                                ),
2486                                (
2487                                    "title".to_owned(),
2488                                    Value::from(format!("needle{}", (index + offset) % 24)),
2489                                ),
2490                            ]),
2491                            base_version: None,
2492                        }
2493                    }
2494                })
2495                .collect::<Vec<_>>();
2496            reference.overlay_dirty.set(true);
2497            incremental.mutate(mutations.clone()).unwrap();
2498            reference.mutate(mutations).unwrap();
2499            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2500                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2501                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2502            }
2503            assert_eq!(incremental.local_revision(), reference.local_revision());
2504            assert_eq!(
2505                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2506                serde_json::to_value(reference.drain_change_batches()).unwrap()
2507            );
2508        }
2509        assert_eq!(incremental.outbox.len(), 100);
2510        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2511        assert_eq!(reference.overlay_rebuild_count.get(), 100);
2512    }
2513
2514    #[test]
2515    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2516        for fail_at in ["outbox", "revision"] {
2517            let mut client = client();
2518            let values = Map::from_iter([
2519                ("id".to_owned(), Value::from("t1")),
2520                ("project_id".to_owned(), Value::from("p1")),
2521            ]);
2522            let mutation = Mutation::Upsert {
2523                table: "tasks".to_owned(),
2524                values,
2525                base_version: None,
2526            };
2527            let trigger = if fail_at == "outbox" {
2528                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2529            } else {
2530                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2531            };
2532            client.conn.execute_batch(&trigger).unwrap();
2533            let revision = client.local_revision();
2534            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2535            assert_eq!(client.local_revision(), revision);
2536            assert!(client.outbox.is_empty());
2537            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2538            assert_eq!(
2539                client
2540                    .conn
2541                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2542                        .get::<_, i64>(0))
2543                    .unwrap(),
2544                0
2545            );
2546            assert!(client.drain_change_batches().is_empty());
2547            assert!(!client.overlay_dirty.get());
2548            client
2549                .conn
2550                .execute_batch("DROP TRIGGER fail_append")
2551                .unwrap();
2552            client.mutate(vec![mutation]).unwrap();
2553            assert_eq!(client.outbox.len(), 1);
2554            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2555        }
2556    }
2557
2558    #[test]
2559    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2560        let mut client = client();
2561        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2562        let values = Map::from_iter([
2563            ("id".to_owned(), Value::from("t1")),
2564            ("project_id".to_owned(), Value::from("p1")),
2565        ]);
2566        let payload = encode_row_json(
2567            client.schema.table("tasks").unwrap(),
2568            "t1",
2569            &values,
2570            &client.encryption,
2571        )
2572        .unwrap();
2573        let first = ssp2::model::Change {
2574            table_index: 0,
2575            row_id: "t1".to_owned(),
2576            op: Op::Upsert,
2577            row_version: Some(1),
2578            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2579            row: Some(payload),
2580        };
2581        let mut invalid = first.clone();
2582        invalid.table_index = 1;
2583        let revision = client.local_revision();
2584        client.begin_observation("failed_mirror").unwrap();
2585        assert!(client
2586            .apply_commit_changes(&tables, &[first, invalid])
2587            .is_err());
2588        client.rollback_observation("failed_mirror");
2589        client.rebuild_overlay_if_dirty();
2590        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2591        assert_eq!(
2592            client
2593                .conn
2594                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2595                    .get::<_, i64>(
2596                    0
2597                ))
2598                .unwrap(),
2599            0
2600        );
2601        assert_eq!(client.local_revision(), revision);
2602        assert!(client.drain_change_batches().is_empty());
2603    }
2604
2605    #[test]
2606    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2607        for failure in ["journal", "revision", "commit", "later-frame"] {
2608            let mut client = client();
2609            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2610            for id in ["first", "second"] {
2611                client
2612                    .mutate(vec![Mutation::Upsert {
2613                        table: "tasks".to_owned(),
2614                        values: Map::from_iter([
2615                            ("id".to_owned(), Value::from(id)),
2616                            ("project_id".to_owned(), Value::from("p1")),
2617                        ]),
2618                        base_version: None,
2619                    }])
2620                    .unwrap();
2621            }
2622            let (_, meta) = client.build_request(false);
2623            // A malicious or stale response must not drain an unsent local commit.
2624            client
2625                .mutate(vec![Mutation::Upsert {
2626                    table: "tasks".to_owned(),
2627                    values: Map::from_iter([
2628                        ("id".to_owned(), Value::from("later")),
2629                        ("project_id".to_owned(), Value::from("p1")),
2630                    ]),
2631                    base_version: None,
2632                }])
2633                .unwrap();
2634            let ids = client.pending_commit_ids();
2635            let rows = client
2636                .query("SELECT * FROM tasks ORDER BY id", &[])
2637                .unwrap();
2638            let revision = client.local_revision();
2639            client.drain_change_batches();
2640            if failure != "later-frame" {
2641                client.conn.execute_batch(match failure {
2642                    "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",
2643                    "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",
2644                    _ => "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",
2645                }).unwrap();
2646            }
2647            let mut response = Message {
2648                wire_version: WIRE_VERSION,
2649                msg_kind: MsgKind::Response,
2650                frames: vec![Frame::RespHeader {
2651                    required_schema_version: None,
2652                    latest_schema_version: None,
2653                    log_epoch: Some("epoch-1".to_owned()),
2654                    reset_required: Some(false),
2655                }],
2656            };
2657            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2658                response.frames.push(Frame::PushResult {
2659                    client_commit_id: id.to_owned(),
2660                    status: PushStatus::Applied,
2661                    commit_seq: Some(1),
2662                    results: vec![OpResult::Applied { op_index: 0 }],
2663                });
2664            }
2665            if failure == "later-frame" {
2666                response.frames.push(Frame::Error {
2667                    code: "sync.invalid_request".to_owned(),
2668                    message: "later error".to_owned(),
2669                    category: "protocol".to_owned(),
2670                    retryable: false,
2671                    recommended_action: "retry".to_owned(),
2672                    details: None,
2673                });
2674            }
2675            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2676            let outcome = client.process_response(&mut transport, response.clone(), &meta);
2677            assert!(
2678                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2679            );
2680            if failure == "later-frame" {
2681                assert_eq!(client.pending_commit_ids(), ids[2..]);
2682                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2683                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2684                let batches = client.drain_change_batches();
2685                assert_eq!(batches.len(), 1);
2686                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2687                assert_eq!(client.local_revision(), revision + 1);
2688                response.frames.pop();
2689            } else {
2690                assert_eq!(client.pending_commit_ids(), ids);
2691                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2692                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2693                assert_eq!(client.local_revision(), revision);
2694                assert!(client.drain_change_batches().is_empty());
2695                assert_eq!(
2696                    client
2697                        .query("SELECT * FROM tasks ORDER BY id", &[])
2698                        .unwrap(),
2699                    rows
2700                );
2701                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2702            }
2703            for frame in &mut response.frames {
2704                if let Frame::PushResult { status, .. } = frame {
2705                    *status = PushStatus::Cached;
2706                }
2707            }
2708            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2709            else {
2710                panic!("retry failed");
2711            };
2712            assert_eq!(
2713                report.applied,
2714                if failure == "later-frame" {
2715                    vec![]
2716                } else {
2717                    ids[..2].to_vec()
2718                }
2719            );
2720            assert_eq!(client.pending_commit_ids(), ids[2..]);
2721            let batches = client.drain_change_batches();
2722            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2723            if let Some(batch) = batches.first() {
2724                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2725            }
2726            assert_eq!(client.local_revision(), revision + 1);
2727        }
2728    }
2729
2730    #[test]
2731    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2732        for failure in ["revision", "journal", "commit"] {
2733            for status in ["applied", "cached", "rejected", "conflict"] {
2734                let mut client = client();
2735                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2736                for id in ["first", "later"] {
2737                    client
2738                        .mutate(vec![Mutation::Upsert {
2739                            table: "tasks".to_owned(),
2740                            values: Map::from_iter([
2741                                ("id".to_owned(), Value::from(id)),
2742                                ("project_id".to_owned(), Value::from("p1")),
2743                            ]),
2744                            base_version: None,
2745                        }])
2746                        .unwrap();
2747                }
2748                let (_, meta) = client.build_request(false);
2749                let ids = client.pending_commit_ids();
2750                let rows = client
2751                    .query("SELECT * FROM tasks ORDER BY id", &[])
2752                    .unwrap();
2753                let revision = client.local_revision();
2754                client.drain_change_batches();
2755                let (push_status, results) = match status {
2756                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2757                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2758                    "conflict" => (
2759                        PushStatus::Rejected,
2760                        vec![OpResult::Conflict {
2761                            op_index: 0,
2762                            code: "sync.version_conflict".to_owned(),
2763                            message: "conflict".to_owned(),
2764                            server_version: 1,
2765                            server_row: encode_row_json(
2766                                client.schema.table("tasks").unwrap(),
2767                                "first",
2768                                &Map::from_iter([
2769                                    ("id".to_owned(), Value::from("first")),
2770                                    ("project_id".to_owned(), Value::from("p1")),
2771                                ]),
2772                                &client.encryption,
2773                            )
2774                            .unwrap(),
2775                            conflict_columns: Vec::new(),
2776                        }],
2777                    ),
2778                    _ => (
2779                        PushStatus::Rejected,
2780                        vec![OpResult::Error {
2781                            op_index: 0,
2782                            code: "sync.validation_failed".to_owned(),
2783                            message: "rejected".to_owned(),
2784                            retryable: false,
2785                        }],
2786                    ),
2787                };
2788                let response = Message {
2789                    wire_version: WIRE_VERSION,
2790                    msg_kind: MsgKind::Response,
2791                    frames: vec![
2792                        Frame::RespHeader {
2793                            required_schema_version: None,
2794                            latest_schema_version: None,
2795                            log_epoch: Some("epoch-1".to_owned()),
2796                            reset_required: Some(false),
2797                        },
2798                        Frame::PushResult {
2799                            client_commit_id: ids[0].clone(),
2800                            status: push_status,
2801                            commit_seq: Some(1),
2802                            results,
2803                        },
2804                    ],
2805                };
2806                client.conn.execute_batch(match failure {
2807                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2808                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2809                    _ => "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",
2810                }).unwrap();
2811                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2812                let outcome = client.process_response(&mut transport, response.clone(), &meta);
2813                assert_eq!(
2814                    client.pending_commit_ids(),
2815                    ids,
2816                    "{failure}/{status}: pending identity"
2817                );
2818                assert_eq!(
2819                    client.local_revision(),
2820                    revision,
2821                    "{failure}/{status}: revision"
2822                );
2823                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2824                assert!(client.conflicts().is_empty());
2825                assert!(client.rejections().is_empty());
2826                assert!(client.drain_change_batches().is_empty());
2827                assert_eq!(
2828                    client
2829                        .query("SELECT * FROM tasks ORDER BY id", &[])
2830                        .unwrap(),
2831                    rows
2832                );
2833                assert!(
2834                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2835                    "{failure}/{status}: failed round"
2836                );
2837                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2838                assert!(matches!(
2839                    client.process_response(&mut transport, response, &meta),
2840                    SyncOutcome::Ok(_)
2841                ));
2842                assert_eq!(client.pending_commit_ids(), ids[1..]);
2843                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2844                assert_eq!(client.drain_change_batches().len(), 1);
2845            }
2846        }
2847    }
2848
2849    #[test]
2850    fn mixed_push_results_reconcile_and_prune_once_per_response() {
2851        let mut client = client();
2852        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2853        for index in 0..4 {
2854            client
2855                .mutate(vec![Mutation::Upsert {
2856                    table: "tasks".to_owned(),
2857                    values: Map::from_iter([
2858                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2859                        ("project_id".to_owned(), Value::from("project-1")),
2860                    ]),
2861                    base_version: None,
2862                }])
2863                .expect("queue commit");
2864        }
2865
2866        let (_, request_meta) = client.build_request(false);
2867        let ids = &request_meta.pushed_ids;
2868        assert_eq!(ids.len(), 4);
2869        let response = Message {
2870            wire_version: WIRE_VERSION,
2871            msg_kind: MsgKind::Response,
2872            frames: vec![
2873                Frame::RespHeader {
2874                    required_schema_version: None,
2875                    latest_schema_version: None,
2876                    log_epoch: Some("epoch-1".to_owned()),
2877                    reset_required: Some(false),
2878                },
2879                Frame::PushResult {
2880                    client_commit_id: ids[0].clone(),
2881                    status: PushStatus::Applied,
2882                    commit_seq: Some(1),
2883                    results: vec![OpResult::Applied { op_index: 0 }],
2884                },
2885                Frame::PushResult {
2886                    client_commit_id: ids[1].clone(),
2887                    status: PushStatus::Cached,
2888                    commit_seq: Some(2),
2889                    results: vec![OpResult::Applied { op_index: 0 }],
2890                },
2891                Frame::PushResult {
2892                    client_commit_id: ids[2].clone(),
2893                    status: PushStatus::Rejected,
2894                    commit_seq: None,
2895                    results: vec![OpResult::Error {
2896                        op_index: 0,
2897                        code: "sync.validation_failed".to_owned(),
2898                        message: "rejected".to_owned(),
2899                        retryable: false,
2900                    }],
2901                },
2902                Frame::PushResult {
2903                    client_commit_id: ids[3].clone(),
2904                    status: PushStatus::Rejected,
2905                    commit_seq: None,
2906                    results: vec![OpResult::Error {
2907                        op_index: 0,
2908                        code: "sync.idempotency_cache_miss".to_owned(),
2909                        message: "retry".to_owned(),
2910                        retryable: true,
2911                    }],
2912                },
2913            ],
2914        };
2915        let mut transport =
2916            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2917
2918        client.overlay_rebuild_count.set(0);
2919        client.outcome_prune_count.set(0);
2920        let outcome = client.process_response(&mut transport, response, &request_meta);
2921        let SyncOutcome::Ok(report) = outcome else {
2922            panic!("mixed push-result response failed");
2923        };
2924
2925        assert_eq!(report.applied, ids[..2]);
2926        assert_eq!(report.rejected, ids[2..3]);
2927        assert_eq!(report.retryable, ids[3..4]);
2928        assert_eq!(client.pending_commit_ids(), ids[3..4]);
2929        assert_eq!(
2930            client
2931                .query("SELECT id FROM tasks ORDER BY id", &[])
2932                .expect("query visible overlay"),
2933            vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2934        );
2935        assert_eq!(client.overlay_rebuild_count.get(), 1);
2936        assert_eq!(client.outcome_prune_count.get(), 1);
2937    }
2938
2939    #[test]
2940    fn secondary_unique_collision_preserves_existing_synced_row() {
2941        let client = SyncClient::new(
2942            "unique-upsert-test".to_owned(),
2943            &json!({
2944                "version": 1,
2945                "tables": [{
2946                    "name": "tasks",
2947                    "primaryKey": "id",
2948                    "columns": [
2949                        { "name": "id", "type": "string", "nullable": false },
2950                        { "name": "project_id", "type": "string", "nullable": false },
2951                        { "name": "title", "type": "string", "nullable": false }
2952                    ],
2953                    "scopes": [{ "pattern": "project:{project_id}" }],
2954                    "indexes": [{
2955                        "name": "tasks_by_project_title",
2956                        "columns": ["project_id", "title"],
2957                        "unique": true
2958                    }]
2959                }]
2960            }),
2961            ClientLimits::default(),
2962        )
2963        .expect("test client");
2964        let table = client.schema.table("tasks").expect("tasks table");
2965        let sql = client.insert_row_sql(&base_table("tasks"), table);
2966
2967        client
2968            .conn
2969            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2970            .expect("insert first row");
2971        client
2972            .conn
2973            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2974            .expect("update same primary key");
2975        client
2976            .conn
2977            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2978            .expect("insert second row");
2979        assert!(client
2980            .conn
2981            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2982            .is_err());
2983
2984        let rows = client
2985            .conn
2986            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2987            .expect("prepare rows")
2988            .query_map([], |row| {
2989                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2990            })
2991            .expect("query rows")
2992            .collect::<Result<Vec<_>, _>>()
2993            .expect("collect rows");
2994        assert_eq!(
2995            rows,
2996            vec![
2997                ("t1".to_owned(), "updated".to_owned()),
2998                ("t2".to_owned(), "original".to_owned())
2999            ]
3000        );
3001    }
3002
3003    #[test]
3004    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3005        let path = std::env::temp_dir().join(format!(
3006            "syncular-startup-intent-{}.db",
3007            uuid::Uuid::new_v4()
3008        ));
3009        let schema = json!({
3010            "version": 1,
3011            "tables": [{
3012                "name": "tasks",
3013                "primaryKey": "id",
3014                "columns": [
3015                    { "name": "id", "type": "string", "nullable": false },
3016                    { "name": "project_id", "type": "string", "nullable": false }
3017                ],
3018                "scopes": [{ "pattern": "project:{project_id}" }]
3019            }]
3020        });
3021
3022        {
3023            let mut first = SyncClient::open_path_with_identity(
3024                None,
3025                &schema,
3026                ClientLimits::default(),
3027                path.to_str().expect("UTF-8 temp path"),
3028            )
3029            .expect("first open");
3030            first
3031                .set_window(
3032                    &WindowBase {
3033                        table: "tasks".to_owned(),
3034                        variable: "project_id".to_owned(),
3035                        fixed_scopes: Vec::new(),
3036                        params: None,
3037                    },
3038                    &["persisted".to_owned()],
3039                )
3040                .expect("persist window");
3041        }
3042
3043        let mut reopened = SyncClient::open_path_with_identity(
3044            None,
3045            &schema,
3046            ClientLimits::default(),
3047            path.to_str().expect("UTF-8 temp path"),
3048        )
3049        .expect("reopen");
3050        assert!(reopened.sync_needed());
3051        assert!(matches!(
3052            reopened.drain_sync_intents().as_slice(),
3053            [SyncIntent::Interactive]
3054        ));
3055        drop(reopened);
3056        std::fs::remove_file(path).expect("remove temp database");
3057    }
3058
3059    #[test]
3060    fn reopening_preserves_immutable_subscription_identity_and_progress() {
3061        let path = std::env::temp_dir().join(format!(
3062            "syncular-subscription-identity-{}.db",
3063            uuid::Uuid::new_v4()
3064        ));
3065        let schema = json!({
3066            "version": 1,
3067            "tables": [
3068                {
3069                    "name": "tasks",
3070                    "primaryKey": "id",
3071                    "columns": [
3072                        { "name": "id", "type": "string", "nullable": false },
3073                        { "name": "project_id", "type": "string", "nullable": false }
3074                    ],
3075                    "scopes": [{ "pattern": "project:{project_id}" }]
3076                },
3077                {
3078                    "name": "docs",
3079                    "primaryKey": "id",
3080                    "columns": [
3081                        { "name": "id", "type": "string", "nullable": false },
3082                        { "name": "org_id", "type": "string", "nullable": false },
3083                        { "name": "project_id", "type": "string", "nullable": false }
3084                    ],
3085                    "scopes": [
3086                        { "pattern": "org:{org_id}" },
3087                        { "pattern": "project:{projectId}", "column": "project_id" }
3088                    ]
3089                }
3090            ]
3091        });
3092
3093        {
3094            let mut first = SyncClient::open_path_with_identity(
3095                None,
3096                &schema,
3097                ClientLimits::default(),
3098                path.to_str().expect("UTF-8 temp path"),
3099            )
3100            .expect("first open");
3101            first
3102                .subscribe(
3103                    "stable-subscription".to_owned(),
3104                    "tasks".to_owned(),
3105                    vec![(
3106                        "project_id".to_owned(),
3107                        vec!["p2".to_owned(), "p1".to_owned()],
3108                    )],
3109                    Some(r#"{"view":"v1"}"#.to_owned()),
3110                )
3111                .expect("persist subscription");
3112            let persisted = {
3113                let subscription = first
3114                    .subs
3115                    .iter_mut()
3116                    .find(|subscription| subscription.id == "stable-subscription")
3117                    .expect("subscription");
3118                subscription.cursor = 41;
3119                subscription.bootstrap_state = Some("resume-token".to_owned());
3120                subscription.effective = Some(vec![(
3121                    "project_id".to_owned(),
3122                    vec!["p1".to_owned(), "p2".to_owned()],
3123                )]);
3124                subscription.synced_once = true;
3125                subscription.clone()
3126            };
3127            first.persist_sub(&persisted).unwrap();
3128        }
3129
3130        let mut reopened = SyncClient::open_path_with_identity(
3131            None,
3132            &schema,
3133            ClientLimits::default(),
3134            path.to_str().expect("UTF-8 temp path"),
3135        )
3136        .expect("reopen");
3137        let progress = reopened
3138            .subscription_state("stable-subscription")
3139            .expect("persisted state");
3140        assert_eq!(progress.cursor, 41);
3141        assert!(progress.has_resume_token);
3142
3143        reopened
3144            .subscribe(
3145                "stable-subscription".to_owned(),
3146                "tasks".to_owned(),
3147                vec![(
3148                    "project_id".to_owned(),
3149                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3150                )],
3151                Some(r#"{"view":"v1"}"#.to_owned()),
3152            )
3153            .expect("canonical intent is idempotent");
3154        assert_eq!(
3155            reopened
3156                .subscription_state("stable-subscription")
3157                .expect("unchanged state")
3158                .cursor,
3159            progress.cursor
3160        );
3161
3162        for (table, scopes, params) in [
3163            (
3164                "tasks",
3165                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3166                Some(r#"{"view":"v1"}"#.to_owned()),
3167            ),
3168            (
3169                "tasks",
3170                vec![(
3171                    "project_id".to_owned(),
3172                    vec!["p2".to_owned(), "p1".to_owned()],
3173                )],
3174                Some(r#"{"view":"v2"}"#.to_owned()),
3175            ),
3176            (
3177                "docs",
3178                vec![
3179                    ("org_id".to_owned(), vec!["o1".to_owned()]),
3180                    ("projectId".to_owned(), vec!["p1".to_owned()]),
3181                ],
3182                Some(r#"{"view":"v1"}"#.to_owned()),
3183            ),
3184        ] {
3185            let error = reopened
3186                .subscribe(
3187                    "stable-subscription".to_owned(),
3188                    table.to_owned(),
3189                    scopes,
3190                    params,
3191                )
3192                .expect_err("identity rebind must fail");
3193            assert!(error.starts_with("client.subscription_intent_mismatch:"));
3194            assert_eq!(
3195                reopened
3196                    .subscription_state("stable-subscription")
3197                    .expect("unchanged state")
3198                    .cursor,
3199                progress.cursor
3200            );
3201        }
3202
3203        drop(reopened);
3204        std::fs::remove_file(path).expect("remove temp database");
3205    }
3206
3207    #[test]
3208    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3209        let path = std::env::temp_dir().join(format!(
3210            "syncular-satisfied-schema-floor-{}.db",
3211            uuid::Uuid::new_v4()
3212        ));
3213        let schema = json!({
3214            "version": 23,
3215            "tables": [{
3216                "name": "tasks",
3217                "primaryKey": "id",
3218                "columns": [
3219                    { "name": "id", "type": "string", "nullable": false },
3220                    { "name": "project_id", "type": "string", "nullable": false }
3221                ],
3222                "scopes": [{ "pattern": "project:{project_id}" }]
3223            }]
3224        });
3225
3226        {
3227            let mut first = SyncClient::open_path_with_identity(
3228                None,
3229                &schema,
3230                ClientLimits::default(),
3231                path.to_str().expect("UTF-8 temp path"),
3232            )
3233            .expect("first open");
3234            first
3235                .subscribe(
3236                    "tasks".to_owned(),
3237                    "tasks".to_owned(),
3238                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3239                    None,
3240                )
3241                .expect("persist subscription");
3242            first.set_schema_floor(Some(SchemaFloor {
3243                required_schema_version: Some(22),
3244                latest_schema_version: Some(22),
3245            }));
3246        }
3247
3248        let mut reopened = SyncClient::open_path_with_identity(
3249            None,
3250            &schema,
3251            ClientLimits::default(),
3252            path.to_str().expect("UTF-8 temp path"),
3253        )
3254        .expect("reopen");
3255        assert!(reopened.schema_floor().is_none());
3256        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3257        assert!(reopened.sync_needed());
3258        assert!(matches!(
3259            reopened.drain_sync_intents().as_slice(),
3260            [SyncIntent::Interactive]
3261        ));
3262        drop(reopened);
3263        std::fs::remove_file(path).expect("remove temp database");
3264    }
3265
3266    #[test]
3267    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3268        let path = std::env::temp_dir().join(format!(
3269            "syncular-unsatisfied-schema-floor-{}.db",
3270            uuid::Uuid::new_v4()
3271        ));
3272        let schema = json!({
3273            "version": 1,
3274            "tables": [{
3275                "name": "tasks",
3276                "primaryKey": "id",
3277                "columns": [
3278                    { "name": "id", "type": "string", "nullable": false },
3279                    { "name": "project_id", "type": "string", "nullable": false }
3280                ],
3281                "scopes": [{ "pattern": "project:{project_id}" }]
3282            }]
3283        });
3284
3285        {
3286            let mut first = SyncClient::open_path_with_identity(
3287                None,
3288                &schema,
3289                ClientLimits::default(),
3290                path.to_str().expect("UTF-8 temp path"),
3291            )
3292            .expect("first open");
3293            first
3294                .subscribe(
3295                    "tasks".to_owned(),
3296                    "tasks".to_owned(),
3297                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3298                    None,
3299                )
3300                .expect("persist subscription");
3301            first.set_schema_floor(Some(SchemaFloor {
3302                required_schema_version: Some(2),
3303                latest_schema_version: Some(2),
3304            }));
3305        }
3306
3307        let reopened = SyncClient::open_path_with_identity(
3308            None,
3309            &schema,
3310            ClientLimits::default(),
3311            path.to_str().expect("UTF-8 temp path"),
3312        )
3313        .expect("reopen");
3314        assert_eq!(
3315            reopened.schema_floor(),
3316            Some(&SchemaFloor {
3317                required_schema_version: Some(2),
3318                latest_schema_version: Some(2),
3319            })
3320        );
3321        assert!(!reopened.sync_needed());
3322        drop(reopened);
3323        std::fs::remove_file(path).expect("remove temp database");
3324    }
3325
3326    #[test]
3327    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3328        let path = std::env::temp_dir().join(format!(
3329            "syncular-indexed-column-bump-{}.db",
3330            uuid::Uuid::new_v4()
3331        ));
3332        let old_schema = json!({
3333            "version": 1,
3334            "tables": [
3335                {
3336                    "name": "tasks",
3337                    "primaryKey": "id",
3338                    "columns": [
3339                        { "name": "id", "type": "string", "nullable": false },
3340                        { "name": "project_id", "type": "string", "nullable": false }
3341                    ],
3342                    "scopes": [{ "pattern": "project:{project_id}" }]
3343                },
3344                {
3345                    "name": "legacy",
3346                    "primaryKey": "id",
3347                    "columns": [
3348                        { "name": "id", "type": "string", "nullable": false },
3349                        { "name": "project_id", "type": "string", "nullable": false }
3350                    ],
3351                    "scopes": [{ "pattern": "project:{project_id}" }]
3352                }
3353            ]
3354        });
3355        {
3356            let mut first = SyncClient::open_path_with_identity(
3357                None,
3358                &old_schema,
3359                ClientLimits::default(),
3360                path.to_str().expect("UTF-8 temp path"),
3361            )
3362            .expect("open old schema");
3363            first
3364                .subscribe(
3365                    "legacy-sub".to_owned(),
3366                    "legacy".to_owned(),
3367                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3368                    None,
3369                )
3370                .expect("persist legacy subscription");
3371        }
3372
3373        let new_schema = json!({
3374            "version": 2,
3375            "tables": [{
3376                "name": "tasks",
3377                "primaryKey": "id",
3378                "columns": [
3379                    { "name": "id", "type": "string", "nullable": false },
3380                    { "name": "project_id", "type": "string", "nullable": false },
3381                    { "name": "facility_membership_id", "type": "string", "nullable": true }
3382                ],
3383                "scopes": [{ "pattern": "project:{project_id}" }],
3384                "indexes": [{
3385                    "name": "tasks_by_membership",
3386                    "columns": ["project_id", "facility_membership_id"],
3387                    "unique": false
3388                }]
3389            }]
3390        });
3391        let upgraded = SyncClient::open_path_with_identity(
3392            None,
3393            &new_schema,
3394            ClientLimits::default(),
3395            path.to_str().expect("UTF-8 temp path"),
3396        )
3397        .expect("open upgraded schema");
3398        let columns = upgraded
3399            .conn
3400            .prepare("PRAGMA table_info(tasks)")
3401            .expect("prepare columns")
3402            .query_map([], |row| row.get::<_, String>(1))
3403            .expect("query columns")
3404            .collect::<Result<Vec<_>, _>>()
3405            .expect("collect columns");
3406        assert!(columns.contains(&"facility_membership_id".to_owned()));
3407        let index_count: i64 = upgraded
3408            .conn
3409            .query_row(
3410                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3411                [],
3412                |row| row.get(0),
3413            )
3414            .expect("query index");
3415        assert_eq!(index_count, 1);
3416        assert!(upgraded.subscription_state("legacy-sub").is_none());
3417        drop(upgraded);
3418        std::fs::remove_file(path).expect("remove temp database");
3419    }
3420
3421    #[test]
3422    fn migrates_pre_envelope_outcome_journal_additively() {
3423        let path = std::env::temp_dir().join(format!(
3424            "syncular-outcome-migration-{}.db",
3425            uuid::Uuid::new_v4()
3426        ));
3427        let conn = Connection::open(&path).expect("open legacy database");
3428        conn.execute_batch(
3429            "CREATE TABLE _syncular_commit_outcomes (
3430               seq INTEGER PRIMARY KEY AUTOINCREMENT,
3431               client_commit_id TEXT NOT NULL UNIQUE,
3432               status TEXT NOT NULL,
3433               recorded_at_ms INTEGER NOT NULL,
3434               results_json TEXT NOT NULL,
3435               resolution TEXT NOT NULL DEFAULT 'active',
3436               resolved_at_ms INTEGER,
3437               replacement_client_commit_id TEXT);",
3438        )
3439        .expect("create legacy outcome journal");
3440        drop(conn);
3441        let schema = json!({
3442            "version": 1,
3443            "tables": [{
3444                "name": "tasks",
3445                "primaryKey": "id",
3446                "columns": [
3447                    { "name": "id", "type": "string", "nullable": false },
3448                    { "name": "project_id", "type": "string", "nullable": false }
3449                ],
3450                "scopes": [{ "pattern": "project:{project_id}" }]
3451            }]
3452        });
3453        let client = SyncClient::open_path(
3454            "migration-native".to_owned(),
3455            &schema,
3456            ClientLimits::default(),
3457            path.to_str().expect("UTF-8 temp path"),
3458        )
3459        .expect("migrate database");
3460        let has_operations = client
3461            .conn
3462            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3463            .expect("prepare table info")
3464            .query_map([], |row| row.get::<_, String>(1))
3465            .expect("query table info")
3466            .filter_map(Result::ok)
3467            .any(|column| column == "operations_json");
3468        assert!(has_operations);
3469        drop(client);
3470        std::fs::remove_file(path).expect("remove temp database");
3471    }
3472
3473    #[test]
3474    fn durable_conflict_outcome_and_resolution_survive_reopen() {
3475        let path = std::env::temp_dir().join(format!(
3476            "syncular-durable-outcome-{}.db",
3477            uuid::Uuid::new_v4()
3478        ));
3479        let schema = json!({
3480            "version": 1,
3481            "tables": [{
3482                "name": "tasks",
3483                "primaryKey": "id",
3484                "columns": [
3485                    { "name": "id", "type": "string", "nullable": false },
3486                    { "name": "project_id", "type": "string", "nullable": false }
3487                ],
3488                "scopes": [{ "pattern": "project:{project_id}" }]
3489            }]
3490        });
3491        let conflict = ConflictRecord {
3492            client_commit_id: "losing-commit".to_owned(),
3493            op_index: 0,
3494            table: "tasks".to_owned(),
3495            row_id: "t1".to_owned(),
3496            code: "sync.version_conflict".to_owned(),
3497            message: "stale base version".to_owned(),
3498            server_version: 2,
3499            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3500            conflict_columns: vec!["project_id".to_owned()],
3501            operation: Some(CommitOperation {
3502                table: "tasks".to_owned(),
3503                row_id: "t1".to_owned(),
3504                op: "upsert".to_owned(),
3505                base_version: Some(1),
3506                values: None,
3507            }),
3508        };
3509        let failed_operations = vec![
3510            OutboxOp {
3511                upsert: true,
3512                table: "tasks".to_owned(),
3513                row_id: "t1".to_owned(),
3514                base_version: Some(1),
3515                values: None,
3516            },
3517            OutboxOp {
3518                upsert: true,
3519                table: "tasks".to_owned(),
3520                row_id: "status-event-1".to_owned(),
3521                base_version: Some(0),
3522                values: None,
3523            },
3524        ];
3525
3526        {
3527            let mut first = SyncClient::open_path(
3528                "durable-native".to_owned(),
3529                &schema,
3530                ClientLimits::default(),
3531                path.to_str().expect("UTF-8 temp path"),
3532            )
3533            .expect("first open");
3534            first
3535                .begin_observation("test_outcome")
3536                .expect("begin outcome");
3537            first
3538                .persist_commit_outcome(
3539                    "losing-commit",
3540                    CommitOutcomeStatus::Conflict,
3541                    &[CommitOperationOutcome::Conflict {
3542                        conflict: conflict.clone(),
3543                    }],
3544                    Some(&failed_operations),
3545                )
3546                .expect("persist outcome");
3547            first.conflicts.push(conflict);
3548            first
3549                .finish_observation(
3550                    "test_outcome",
3551                    ChangeAccumulator {
3552                        conflicts: true,
3553                        outcomes: true,
3554                        ..ChangeAccumulator::default()
3555                    },
3556                )
3557                .expect("commit outcome");
3558        }
3559
3560        {
3561            let mut reopened = SyncClient::open_path(
3562                "durable-native".to_owned(),
3563                &schema,
3564                ClientLimits::default(),
3565                path.to_str().expect("UTF-8 temp path"),
3566            )
3567            .expect("reopen");
3568            assert_eq!(reopened.conflicts().len(), 1);
3569            assert_eq!(
3570                reopened.conflicts()[0].conflict_columns,
3571                vec!["project_id".to_owned()],
3572                "conflictColumns survives the journal round trip"
3573            );
3574            let outcome = reopened
3575                .commit_outcome("losing-commit")
3576                .expect("read outcome")
3577                .expect("outcome");
3578            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3579            let operations = outcome.operations.expect("aggregate envelope");
3580            assert_eq!(operations.len(), 2);
3581            assert_eq!(operations[1].row_id, "status-event-1");
3582            let resolved = reopened
3583                .resolve_commit_outcome(ResolveCommitOutcomeInput {
3584                    client_commit_id: "losing-commit".to_owned(),
3585                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
3586                    replacement_client_commit_id: None,
3587                })
3588                .expect("resolve");
3589            assert_eq!(
3590                resolved.resolution,
3591                CommitOutcomeResolution::ResolvedKeepServer
3592            );
3593            assert!(reopened.conflicts().is_empty());
3594        }
3595
3596        let reopened = SyncClient::open_path(
3597            "durable-native".to_owned(),
3598            &schema,
3599            ClientLimits::default(),
3600            path.to_str().expect("UTF-8 temp path"),
3601        )
3602        .expect("second reopen");
3603        assert!(reopened.conflicts().is_empty());
3604        assert_eq!(
3605            reopened
3606                .commit_outcome("losing-commit")
3607                .expect("read outcome")
3608                .expect("outcome")
3609                .resolution,
3610            CommitOutcomeResolution::ResolvedKeepServer
3611        );
3612        drop(reopened);
3613        std::fs::remove_file(path).expect("remove temp database");
3614    }
3615
3616    #[test]
3617    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3618        let path =
3619            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3620        let schema = json!({
3621            "version": 1,
3622            "tables": [{
3623                "name": "tasks",
3624                "primaryKey": "id",
3625                "columns": [
3626                    { "name": "id", "type": "string", "nullable": false },
3627                    { "name": "project_id", "type": "string", "nullable": false }
3628                ],
3629                "scopes": [{ "pattern": "project:{project_id}" }]
3630            }]
3631        });
3632        let mut client = SyncClient::open_path_with_identity(
3633            Some("sidecar-client".to_owned()),
3634            &schema,
3635            ClientLimits::default(),
3636            path.to_str().expect("UTF-8 temp path"),
3637        )
3638        .expect("open owner");
3639        let base = WindowBase {
3640            table: "tasks".to_owned(),
3641            variable: "project_id".to_owned(),
3642            fixed_scopes: Vec::new(),
3643            params: None,
3644        };
3645        client
3646            .set_window(&base, &["one".to_owned()])
3647            .expect("set window");
3648        client
3649            .mutate(vec![Mutation::Upsert {
3650                table: "tasks".to_owned(),
3651                values: Map::from_iter([
3652                    ("id".to_owned(), Value::from("t1")),
3653                    ("project_id".to_owned(), Value::from("one")),
3654                ]),
3655                base_version: None,
3656            }])
3657            .expect("local mutate");
3658
3659        let coverage = [WindowCoverage {
3660            base,
3661            units: vec!["one".to_owned(), "missing".to_owned()],
3662        }];
3663        let owner = client
3664            .query_snapshot(
3665                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3666                &[],
3667                &coverage,
3668                None,
3669            )
3670            .expect("owner snapshot");
3671        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3672        let sidecar = reader
3673            .query_snapshot(
3674                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3675                &[],
3676                &coverage,
3677            )
3678            .expect("sidecar snapshot");
3679
3680        assert_eq!(sidecar.revision, owner.revision);
3681        assert_eq!(sidecar.rows, owner.rows);
3682        assert_eq!(
3683            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3684            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3685        );
3686        assert_eq!(sidecar.revision, "2");
3687        assert_eq!(sidecar.rows[0]["id"], "t1");
3688        assert_eq!(sidecar.rows[0]["server_version"], -1);
3689        assert!(!sidecar.coverage.complete);
3690        assert_eq!(sidecar.coverage.pending.len(), 1);
3691        assert_eq!(sidecar.coverage.missing.len(), 1);
3692
3693        drop(reader);
3694        drop(client);
3695        std::fs::remove_file(path).expect("remove temp database");
3696    }
3697
3698    #[test]
3699    fn owned_query_failures_classify_order_bound_and_clear() {
3700        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3701            rusqlite::ffi::Error::new(266),
3702            Some("private SQLite prose".to_owned()),
3703        ));
3704        assert_eq!(io.code, Some("client.storage_io"));
3705        assert_eq!(io.sqlite_code, Some(266));
3706        assert_eq!(io.message, "local SQLite storage I/O failed");
3707        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3708            rusqlite::ffi::Error::new(26),
3709            None,
3710        ));
3711        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
3712
3713        let mut client = client();
3714        let generic = QueryReadFailure::from("private query prose".to_owned());
3715        client.record_query_read(
3716            QueryOwner {
3717                id: "first",
3718                tables: &["tasks", "tasks"],
3719            },
3720            Some(&generic),
3721        );
3722        client.record_query_read(
3723            QueryOwner {
3724                id: "second",
3725                tables: &["tasks"],
3726            },
3727            Some(&generic),
3728        );
3729        client.record_query_read(
3730            QueryOwner {
3731                id: "first",
3732                tables: &["docs"],
3733            },
3734            Some(&io),
3735        );
3736        let failures = &client
3737            .diagnostics_snapshot(&Default::default())
3738            .expect("diagnostics")
3739            .query_failures;
3740        assert_eq!(
3741            failures
3742                .iter()
3743                .map(|entry| entry.id.as_str())
3744                .collect::<Vec<_>>(),
3745            vec!["second", "first"]
3746        );
3747        assert_eq!(failures[1].tables, vec!["docs"]);
3748        assert_eq!(failures[1].code, "client.storage_io");
3749
3750        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
3751            client.record_query_read(
3752                QueryOwner {
3753                    id: &format!("query-{index}"),
3754                    tables: &["tasks"],
3755                },
3756                Some(&generic),
3757            );
3758        }
3759        let failures = &client
3760            .diagnostics_snapshot(&Default::default())
3761            .expect("bounded diagnostics")
3762            .query_failures;
3763        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
3764        assert_eq!(failures.last().expect("last failure").id, "query-256");
3765
3766        client
3767            .query_snapshot(
3768                "SELECT id FROM tasks",
3769                &[],
3770                &[],
3771                Some(QueryOwner {
3772                    id: "query-256",
3773                    tables: &["tasks"],
3774                }),
3775            )
3776            .expect("successful owned read");
3777        assert!(client
3778            .diagnostics_snapshot(&Default::default())
3779            .expect("cleared diagnostics")
3780            .query_failures
3781            .iter()
3782            .all(|entry| entry.id != "query-256"));
3783    }
3784
3785    #[test]
3786    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3787        let mut client = client();
3788        let image = Connection::open_in_memory().unwrap();
3789        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3790            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3791            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3792            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3793            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3794        let table = client.schema.table("tasks").unwrap().clone();
3795        let scopes = vec![("project_id".into(), vec!["p1".into()])];
3796        assert!(client
3797            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3798            .is_err());
3799        assert!(client.conn.is_autocommit());
3800        assert_eq!(
3801            client
3802                .query("SELECT count(*) AS n FROM tasks", &[])
3803                .unwrap()[0]["n"],
3804            1024
3805        );
3806        assert_eq!(client.drain_change_batches().len(), 1);
3807        image
3808            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3809            .unwrap();
3810        assert!(matches!(
3811            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3812            Ok(2050)
3813        ));
3814        assert_eq!(
3815            client
3816                .query("SELECT count(*) AS n FROM tasks", &[])
3817                .unwrap()[0]["n"],
3818            2050
3819        );
3820        assert_eq!(client.drain_change_batches().len(), 3);
3821    }
3822
3823    #[test]
3824    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3825        let mut client = client();
3826        let base = WindowBase {
3827            table: "tasks".into(),
3828            variable: "project_id".into(),
3829            fixed_scopes: Vec::new(),
3830            params: None,
3831        };
3832        client
3833            .set_window(&base, &["p1".into(), "p2".into()])
3834            .unwrap();
3835        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3836            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3837            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3838            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3839            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3840        client.drain_change_batches();
3841        assert!(client.set_window(&base, &["p2".into()]).is_err());
3842        assert!(client.conn.is_autocommit());
3843        assert_eq!(
3844            client
3845                .query("SELECT count(*) AS n FROM tasks", &[])
3846                .unwrap()[0]["n"],
3847            1027
3848        );
3849        assert_eq!(client.window_state(&base).units, vec!["p2"]);
3850        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3851        assert_eq!(client.drain_change_batches().len(), 1);
3852        client
3853            .conn
3854            .execute_batch("DROP TRIGGER interrupt_evict")
3855            .unwrap();
3856        client.drain_pending_evictions().unwrap();
3857        assert!(client.load_pending_evictions().unwrap().is_empty());
3858        assert_eq!(
3859            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3860            "held"
3861        );
3862        assert_eq!(client.drain_change_batches().len(), 2);
3863    }
3864
3865    #[test]
3866    fn eviction_marker_failures_roll_back_window_changes() {
3867        let mut client = client();
3868        let base = WindowBase {
3869            table: "tasks".into(),
3870            variable: "project_id".into(),
3871            fixed_scopes: Vec::new(),
3872            params: None,
3873        };
3874        client.set_window(&base, &["p1".into()]).unwrap();
3875        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3876            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3877            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3878            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3879        let revision = client.local_revision();
3880        assert!(client.set_window(&base, &[]).is_err());
3881        assert!(client.conn.is_autocommit());
3882        assert_eq!(client.local_revision(), revision);
3883        assert_eq!(
3884            client
3885                .query("SELECT count(*) AS n FROM tasks", &[])
3886                .unwrap()[0]["n"],
3887            2050
3888        );
3889        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3890        client.conn.execute_batch("DROP TRIGGER fail_marker;
3891            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3892        assert!(client.set_window(&base, &[]).is_err());
3893        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3894            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3895        assert!(client.drain_pending_evictions().is_err());
3896        assert!(client.conn.is_autocommit());
3897        assert_eq!(
3898            client
3899                .query("SELECT count(*) AS n FROM tasks", &[])
3900                .unwrap()[0]["n"],
3901            2
3902        );
3903        assert!(client.set_window(&base, &["p1".into()]).is_err());
3904        assert!(client.conn.is_autocommit());
3905        assert!(client.window_state(&base).units.is_empty());
3906        assert!(client.subs.is_empty());
3907        assert_eq!(
3908            client
3909                .query(
3910                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3911                    &[]
3912                )
3913                .unwrap()[0]["n"],
3914            1
3915        );
3916        client
3917            .conn
3918            .execute_batch("DROP TRIGGER fail_marker")
3919            .unwrap();
3920        client.set_window(&base, &["p1".into()]).unwrap();
3921        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3922    }
3923
3924    #[test]
3925    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3926        let mut client = client();
3927        client
3928            .conn
3929            .execute(
3930                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3931                [],
3932            )
3933            .unwrap();
3934        assert_eq!(
3935            client.drain_pending_evictions().unwrap_err(),
3936            "sync.local_corrupt: invalid pending eviction scopes"
3937        );
3938        assert!(client.conn.is_autocommit());
3939        assert!(client.enqueue_startup_sync_if_needed().is_err());
3940    }
3941
3942    #[test]
3943    fn image_chunks_reject_null_primary_keys() {
3944        let mut client = client();
3945        let image = Connection::open_in_memory().unwrap();
3946        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3947            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3948            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3949            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3950            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3951        let table = client.schema.table("tasks").unwrap().clone();
3952        assert!(client
3953            .apply_sqlite_image(
3954                &image,
3955                &table,
3956                true,
3957                &[("project_id".into(), vec!["p1".into()])],
3958                1025,
3959                7,
3960                "digest"
3961            )
3962            .is_err());
3963        assert!(client.conn.is_autocommit());
3964        assert_eq!(
3965            client
3966                .query("SELECT count(*) AS n FROM tasks", &[])
3967                .unwrap()[0]["n"],
3968            0
3969        );
3970    }
3971
3972    #[test]
3973    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
3974        for images in [false, true] {
3975            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3976                "name":name,"primaryKey":"id","columns":[
3977                    {"name":"id","type":"string","nullable":false},
3978                    {"name":"project_id","type":"string","nullable":false},
3979                    {"name":"title","type":"string","nullable":false}],
3980                "scopes":[{"pattern":"project:{project_id}"}],
3981                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
3982                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3983            })))});
3984            let mut actual =
3985                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
3986            let mut reference =
3987                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
3988            let scopes = vec![("project_id".into(), vec!["p1".into()])];
3989            for client in [&mut actual, &mut reference] {
3990                client
3991                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
3992                    .unwrap();
3993                client.subs[0].effective = Some(scopes.clone());
3994                for (table, id, title) in [
3995                    ("tasks", "a", "original"),
3996                    ("tasks", "b", "occupied"),
3997                    ("tasks", "c", "deleted"),
3998                    ("notes", "n", "untouched"),
3999                ] {
4000                    client
4001                        .write_base_row(
4002                            table,
4003                            &vec![
4004                                Some(ColumnValue::String(id.into())),
4005                                Some(ColumnValue::String("p1".into())),
4006                                Some(ColumnValue::String(title.into())),
4007                            ],
4008                            1,
4009                        )
4010                        .unwrap();
4011                }
4012                client.rebuild_overlay_if_dirty();
4013                client
4014                    .mutate(vec![
4015                        Mutation::Upsert {
4016                            table: "tasks".into(),
4017                            values: Map::from_iter([
4018                                ("id".into(), Value::from("a")),
4019                                ("project_id".into(), Value::from("p1")),
4020                                ("title".into(), Value::from("available")),
4021                            ]),
4022                            base_version: None,
4023                        },
4024                        Mutation::Delete {
4025                            table: "tasks".into(),
4026                            row_id: "c".into(),
4027                            base_version: None,
4028                        },
4029                        Mutation::Upsert {
4030                            table: "tasks".into(),
4031                            values: Map::from_iter([
4032                                ("id".into(), Value::from("local")),
4033                                ("project_id".into(), Value::from("p1")),
4034                                ("title".into(), Value::from("local value")),
4035                            ]),
4036                            base_version: None,
4037                        },
4038                        Mutation::Upsert {
4039                            table: "notes".into(),
4040                            values: Map::from_iter([
4041                                ("id".into(), Value::from("n")),
4042                                ("project_id".into(), Value::from("p1")),
4043                                ("title".into(), Value::from("pending note")),
4044                            ]),
4045                            base_version: None,
4046                        },
4047                    ])
4048                    .unwrap();
4049            }
4050            let pending = actual.pending_commit_ids();
4051            actual.overlay_rebuild_count.set(0);
4052            for (id, title) in [
4053                ("b", "available"),
4054                ("b", "occupied"),
4055                ("a", "server original"),
4056                ("d", "local value"),
4057                ("d", "released"),
4058                ("c", "redelivered"),
4059            ] {
4060                let table = actual.schema.table("tasks").unwrap().clone();
4061                let values = vec![
4062                    Some(ColumnValue::String(id.into())),
4063                    Some(ColumnValue::String("p1".into())),
4064                    Some(ColumnValue::String(title.into())),
4065                ];
4066                if images {
4067                    let image = Connection::open_in_memory().unwrap();
4068                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4069                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4070                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4071                    image
4072                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4073                        .unwrap();
4074                    assert!(matches!(
4075                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4076                        Ok(1)
4077                    ));
4078                } else {
4079                    let segment = RowsSegment {
4080                        table: "tasks".into(),
4081                        schema_version: 1,
4082                        columns: table.wire_columns.clone(),
4083                        blocks: vec![vec![ssp2::segment::SegmentRow {
4084                            server_version: 2,
4085                            values: values.clone(),
4086                        }]],
4087                    };
4088                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4089                }
4090                reference.write_base_row("tasks", &values, 2).unwrap();
4091                reference.rebuild_overlay();
4092                for table in ["tasks", "notes"] {
4093                    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")] {
4094                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4095                    }
4096                }
4097                assert_eq!(actual.pending_commit_ids(), pending);
4098            }
4099            assert_eq!(
4100                actual.overlay_rebuild_count.get(),
4101                0,
4102                "each chunk must leave unrelated tables alone"
4103            );
4104            let before = actual
4105                .query("SELECT * FROM tasks ORDER BY id", &[])
4106                .unwrap();
4107            let fts_before = actual
4108                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4109                .unwrap();
4110            let revision = actual.local_revision();
4111            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4112            let table = actual.schema.table("tasks").unwrap().clone();
4113            let segment = RowsSegment {
4114                table: "tasks".into(),
4115                schema_version: 1,
4116                columns: table.wire_columns.clone(),
4117                blocks: vec![vec![ssp2::segment::SegmentRow {
4118                    server_version: 3,
4119                    values: vec![
4120                        Some(ColumnValue::String("b".into())),
4121                        Some(ColumnValue::String("p1".into())),
4122                        Some(ColumnValue::String("fresh".into())),
4123                    ],
4124                }]],
4125            };
4126            assert!(actual.apply_segment(0, &segment, false).is_err());
4127            assert!(actual.conn.is_autocommit());
4128            assert_eq!(
4129                actual
4130                    .query("SELECT * FROM tasks ORDER BY id", &[])
4131                    .unwrap(),
4132                before
4133            );
4134            assert_eq!(
4135                actual
4136                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4137                    .unwrap(),
4138                fts_before
4139            );
4140            assert_eq!(actual.local_revision(), revision);
4141            assert_eq!(actual.pending_commit_ids(), pending);
4142        }
4143    }
4144
4145    #[test]
4146    fn import_reconciliation_preserves_typed_pending_primary_keys() {
4147        for (kind, a, b) in [
4148            ("string", json!("a"), json!("b")),
4149            ("integer", json!(1), json!(2)),
4150            ("boolean", json!(true), json!(false)),
4151            ("json", json!("1"), json!("2")),
4152        ] {
4153            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}]}]});
4154            for images in [false, true] {
4155                let mut client =
4156                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4157                let table = client.schema.table("tasks").unwrap().clone();
4158                client
4159                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
4160                    .unwrap();
4161                for (id, title) in [(&a, "original"), (&b, "occupied")] {
4162                    client
4163                        .write_base_row(
4164                            "tasks",
4165                            &vec![
4166                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4167                                Some(ColumnValue::String(title.into())),
4168                            ],
4169                            1,
4170                        )
4171                        .unwrap();
4172                }
4173                client.rebuild_overlay_if_dirty();
4174                client
4175                    .mutate(vec![Mutation::Upsert {
4176                        table: "tasks".into(),
4177                        values: Map::from_iter([
4178                            ("id".into(), a.clone()),
4179                            ("title".into(), json!("available")),
4180                        ]),
4181                        base_version: None,
4182                    }])
4183                    .unwrap();
4184                client.overlay_rebuild_count.set(0);
4185                for (title, expected) in [("available", "original"), ("occupied", "available")] {
4186                    let values = vec![
4187                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4188                        Some(ColumnValue::String(title.into())),
4189                    ];
4190                    if images {
4191                        let image = Connection::open_in_memory().unwrap();
4192                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4193                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4194                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4195                        image
4196                            .execute(
4197                                "INSERT INTO tasks VALUES (?1,?2,2)",
4198                                rusqlite::params![RowParam::Cell(&values[0]), title],
4199                            )
4200                            .unwrap();
4201                        assert!(
4202                            matches!(
4203                                client.apply_sqlite_image(
4204                                    &image,
4205                                    &table,
4206                                    false,
4207                                    &[],
4208                                    1,
4209                                    7,
4210                                    "digest"
4211                                ),
4212                                Ok(1)
4213                            ),
4214                            "{kind}, images={images}"
4215                        );
4216                    } else {
4217                        let segment = RowsSegment {
4218                            table: "tasks".into(),
4219                            schema_version: 1,
4220                            columns: table.wire_columns.clone(),
4221                            blocks: vec![vec![ssp2::segment::SegmentRow {
4222                                server_version: 2,
4223                                values,
4224                            }]],
4225                        };
4226                        assert!(
4227                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
4228                            "{kind}"
4229                        );
4230                    }
4231                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4232                    let actual: String = client
4233                        .conn
4234                        .query_row(
4235                            "SELECT title FROM tasks WHERE id=?1",
4236                            [RowParam::Cell(&key)],
4237                            |row| row.get(0),
4238                        )
4239                        .unwrap();
4240                    assert_eq!(actual, expected, "{kind}, images={images}");
4241                }
4242                assert_eq!(client.overlay_rebuild_count.get(), 0);
4243            }
4244        }
4245    }
4246
4247    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4248        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4249            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4250            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4251            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4252        })))});
4253        let mut client =
4254            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4255        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();
4256        client.rebuild_overlay();
4257        client
4258            .mutate(vec![Mutation::Upsert {
4259                table: "tasks".into(),
4260                values: Map::from_iter([
4261                    ("id".into(), json!("offline")),
4262                    ("project_id".into(), json!("p1")),
4263                    ("title".into(), json!("pending edit")),
4264                ]),
4265                base_version: None,
4266            }])
4267            .unwrap();
4268        let image = Connection::open_in_memory().unwrap();
4269        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4270            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4271        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();
4272        image
4273            .execute(
4274                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4275                [rows],
4276            )
4277            .unwrap();
4278        client.overlay_rebuild_count.set(0);
4279        let table = client.schema.table("tasks").unwrap().clone();
4280        (client, image, table)
4281    }
4282
4283    #[test]
4284    fn large_unique_import_copies_no_unrelated_tables() {
4285        let (mut client, image, table) = unique_import_fixture(10240);
4286        let pending = client.pending_commit_ids();
4287        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4288            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4289        assert!(matches!(
4290            client.apply_sqlite_image(
4291                &image,
4292                &table,
4293                true,
4294                &[("project_id".into(), vec!["p1".into()])],
4295                10240,
4296                7,
4297                "digest"
4298            ),
4299            Ok(10240)
4300        ));
4301        assert_eq!(client.overlay_rebuild_count.get(), 0);
4302        assert_eq!(client.pending_commit_ids(), pending);
4303        assert_eq!(
4304            client
4305                .query("SELECT count(*) AS n FROM tasks", &[])
4306                .unwrap()[0]["n"],
4307            10241
4308        );
4309        assert_eq!(
4310            client
4311                .query("SELECT count(*) AS n FROM tasks_fts", &[])
4312                .unwrap()[0]["n"],
4313            10241
4314        );
4315        assert_eq!(
4316            client
4317                .query("SELECT count(*) AS n FROM notes_fts", &[])
4318                .unwrap()[0]["n"],
4319            8192
4320        );
4321    }
4322
4323    #[test]
4324    #[ignore = "manual scaling benchmark; see bench/README.md"]
4325    fn benchmark_unique_image_import() {
4326        let mut samples = Vec::new();
4327        for rows in [10240, 51200, 102400] {
4328            for trial in 0..4 {
4329                let (mut client, image, table) = unique_import_fixture(rows);
4330                let start = std::time::Instant::now();
4331                assert!(
4332                    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)
4333                );
4334                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4335                assert_eq!(
4336                    client
4337                        .query("SELECT count(*) AS n FROM tasks", &[])
4338                        .unwrap()[0]["n"],
4339                    rows + 1
4340                );
4341                assert_eq!(
4342                    client
4343                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
4344                        .unwrap()[0]["n"],
4345                    rows + 1
4346                );
4347                assert_eq!(
4348                    client
4349                        .query("SELECT count(*) AS n FROM notes_fts", &[])
4350                        .unwrap()[0]["n"],
4351                    8192
4352                );
4353                assert_eq!(client.pending_commit_ids().len(), 1);
4354                if trial > 0 {
4355                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4356                }
4357            }
4358        }
4359        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});
4360        let output = serde_json::to_string_pretty(&artifact).unwrap();
4361        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4362            use std::io::Write;
4363            let mut file = std::fs::OpenOptions::new()
4364                .write(true)
4365                .create_new(true)
4366                .open(path)
4367                .unwrap();
4368            file.write_all(output.as_bytes()).unwrap();
4369        } else {
4370            println!("{output}");
4371        }
4372    }
4373
4374    #[test]
4375    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4376        let schema = json!({
4377            "version": 1,
4378            "tables": [{
4379                "name": "catalogue_codes",
4380                "primaryKey": "id",
4381                "columns": [
4382                    { "name": "id", "type": "string", "nullable": false },
4383                    { "name": "release_id", "type": "string", "nullable": false },
4384                    { "name": "code", "type": "string", "nullable": false },
4385                    { "name": "title", "type": "string", "nullable": false }
4386                ],
4387                "scopes": [{ "pattern": "release:{release_id}" }],
4388                "ftsIndexes": [{
4389                    "name": "catalogue_codes_fts",
4390                    "columns": ["code", "title"],
4391                    "tokenize": "unicode61 remove_diacritics 2"
4392                }]
4393            }]
4394        });
4395        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4396            .expect("FTS5 client");
4397        let insert_trigger: String = client
4398            .conn
4399            .query_row(
4400                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4401                [],
4402                |row| row.get(0),
4403            )
4404            .expect("insert trigger");
4405        let replace_guard: String = client
4406            .conn
4407            .query_row(
4408                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4409                [],
4410                |row| row.get(0),
4411            )
4412            .expect("replace guard");
4413        assert!(!insert_trigger.contains("DELETE FROM"));
4414        assert!(replace_guard.contains("BEFORE INSERT"));
4415        assert!(replace_guard.contains("WHEN EXISTS"));
4416        let search = |client: &SyncClient, query: &str| {
4417            client
4418                .query(
4419                    "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",
4420                    &[Value::from(query)],
4421                )
4422                .expect("FTS query")
4423        };
4424
4425        client
4426            .mutate(vec![Mutation::Upsert {
4427                table: "catalogue_codes".to_owned(),
4428                values: Map::from_iter([
4429                    ("id".to_owned(), Value::from("c1")),
4430                    ("release_id".to_owned(), Value::from("r1")),
4431                    ("code".to_owned(), Value::from("A01")),
4432                    ("title".to_owned(), Value::from("Cholera")),
4433                ]),
4434                base_version: None,
4435            }])
4436            .expect("insert code");
4437        assert_eq!(search(&client, "cholera").len(), 1);
4438        let prior = client
4439            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4440            .unwrap();
4441        client
4442            .conn
4443            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4444            .unwrap();
4445        client.create_synced_tables().unwrap();
4446        assert_eq!(
4447            client
4448                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4449                .unwrap(),
4450            prior
4451        );
4452        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();
4453        let rendered = serde_json::to_string(&plan).unwrap();
4454        assert!(rendered.contains("COVERING INDEX"));
4455        assert!(rendered.contains("INDEX 0:="));
4456        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4457        assert_eq!(search(&client, "cholera").len(), 1);
4458
4459        client
4460            .mutate(vec![Mutation::Upsert {
4461                table: "catalogue_codes".to_owned(),
4462                values: Map::from_iter([
4463                    ("id".to_owned(), Value::from("c1")),
4464                    ("release_id".to_owned(), Value::from("r1")),
4465                    ("code".to_owned(), Value::from("A01")),
4466                    ("title".to_owned(), Value::from("Enteric infection")),
4467                ]),
4468                base_version: None,
4469            }])
4470            .expect("update code");
4471        assert!(search(&client, "cholera").is_empty());
4472        assert_eq!(search(&client, "enteric").len(), 1);
4473
4474        client
4475            .mutate(vec![Mutation::Delete {
4476                table: "catalogue_codes".to_owned(),
4477                row_id: "c1".to_owned(),
4478                base_version: None,
4479            }])
4480            .expect("delete code");
4481        assert!(search(&client, "enteric").is_empty());
4482    }
4483
4484    #[test]
4485    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4486        let schema = json!({
4487            "version": 1,
4488            "tables": [{
4489                "name": "patient_notes",
4490                "primaryKey": "id",
4491                "columns": [
4492                    { "name": "id", "type": "string", "nullable": false },
4493                    { "name": "practice_id", "type": "string", "nullable": false },
4494                    { "name": "encryption_key_id", "type": "string", "nullable": false },
4495                    { "name": "title", "type": "string", "nullable": false }
4496                ],
4497                "scopes": [{ "pattern": "practice:{practice_id}" }],
4498                "ftsIndexes": [{
4499                    "name": "patient_notes_fts",
4500                    "columns": ["title"],
4501                    "tokenize": "unicode61 remove_diacritics 2"
4502                }]
4503            }]
4504        });
4505        let mut client = SyncClient::new(
4506            "local-purge-test".to_owned(),
4507            &schema,
4508            ClientLimits::default(),
4509        )
4510        .expect("local purge client");
4511        client
4512            .conn
4513            .execute(
4514                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4515                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4516                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4517                [],
4518            )
4519            .expect("seed base rows");
4520        client.overlay_dirty.set(true);
4521        client.rebuild_overlay_if_dirty();
4522
4523        let note = |id: &str, key_id: &str, title: &str| {
4524            Map::from_iter([
4525                ("id".to_owned(), Value::from(id)),
4526                ("practice_id".to_owned(), Value::from("practice-1")),
4527                ("encryption_key_id".to_owned(), Value::from(key_id)),
4528                ("title".to_owned(), Value::from(title)),
4529            ])
4530        };
4531        let doomed = client
4532            .mutate(vec![
4533                Mutation::Upsert {
4534                    table: "patient_notes".to_owned(),
4535                    values: note("target", "key-revoked", "Target changed"),
4536                    base_version: None,
4537                },
4538                Mutation::Upsert {
4539                    table: "patient_notes".to_owned(),
4540                    values: note("unrelated", "key-held", "Unrelated changed"),
4541                    base_version: None,
4542                },
4543            ])
4544            .expect("doomed commit");
4545        let kept = client
4546            .mutate(vec![Mutation::Upsert {
4547                table: "patient_notes".to_owned(),
4548                values: note("kept", "key-held", "Kept optimistic"),
4549                base_version: None,
4550            }])
4551            .expect("kept commit");
4552        client.drain_change_batches();
4553
4554        let input = LocalDataPurgeInput {
4555            purge_id: "purge-001".to_owned(),
4556            targets: vec![LocalDataPurgeTarget {
4557                table: "patient_notes".to_owned(),
4558                selectors: BTreeMap::from([(
4559                    "encryption_key_id".to_owned(),
4560                    vec!["key-revoked".to_owned()],
4561                )]),
4562            }],
4563        };
4564        assert_eq!(
4565            client.purge_local_data(&input).expect("apply purge"),
4566            LocalDataPurgeResult {
4567                already_applied: false,
4568                purged_rows: 1,
4569                dropped_commits: 1,
4570            }
4571        );
4572        let rows = client
4573            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4574            .expect("visible rows");
4575        assert_eq!(rows.len(), 2);
4576        assert_eq!(rows[0]["id"], "kept");
4577        assert_eq!(rows[1]["id"], "unrelated");
4578        assert_eq!(rows[1]["title"], "Unrelated original");
4579        let fts = client
4580            .query(
4581                "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'",
4582                &[],
4583            )
4584            .expect("fts query");
4585        assert!(fts.is_empty());
4586        assert_eq!(client.outbox.len(), 1);
4587        assert_eq!(client.outbox[0].client_commit_id, kept);
4588        let outcome = client
4589            .commit_outcome(&doomed)
4590            .expect("read doomed outcome")
4591            .expect("doomed outcome");
4592        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4593        match &outcome.results[0] {
4594            CommitOperationOutcome::Error { rejection } => {
4595                assert_eq!(rejection.code, "client.local_data_purged");
4596                assert_eq!(rejection.client_commit_id, doomed);
4597            }
4598            other => panic!("expected local purge rejection, got {other:?}"),
4599        }
4600        assert_eq!(client.drain_change_batches().len(), 1);
4601        assert_eq!(
4602            client.purge_local_data(&input).expect("retry purge"),
4603            LocalDataPurgeResult {
4604                already_applied: true,
4605                purged_rows: 0,
4606                dropped_commits: 0,
4607            }
4608        );
4609        let conflicting = LocalDataPurgeInput {
4610            purge_id: input.purge_id.clone(),
4611            targets: vec![LocalDataPurgeTarget {
4612                table: "patient_notes".to_owned(),
4613                selectors: BTreeMap::from([(
4614                    "encryption_key_id".to_owned(),
4615                    vec!["key-held".to_owned()],
4616                )]),
4617            }],
4618        };
4619        assert!(client
4620            .purge_local_data(&conflicting)
4621            .expect_err("id collision must fail")
4622            .contains("already used with a different plan"));
4623    }
4624}
4625
4626impl SubState {
4627    fn name(self) -> &'static str {
4628        match self {
4629            SubState::Active => "active",
4630            SubState::Revoked => "revoked",
4631            SubState::Failed => "failed",
4632        }
4633    }
4634
4635    fn parse(value: &str) -> Self {
4636        match value {
4637            "revoked" => Self::Revoked,
4638            "failed" => Self::Failed,
4639            _ => Self::Active,
4640        }
4641    }
4642}
4643
4644#[derive(Debug, Clone)]
4645struct Subscription {
4646    id: String,
4647    table: String,
4648    requested: Vec<(String, Vec<String>)>,
4649    params: Option<String>,
4650    cursor: i64,
4651    /// §4.7 resume token, round-tripped opaquely.
4652    bootstrap_state: Option<String>,
4653    state: SubState,
4654    reason_code: Option<String>,
4655    /// Last effective scopes echoed while active (§3.3: persisted for the
4656    /// purge contract; each active echo replaces it).
4657    effective: Option<Vec<(String, Vec<String>)>>,
4658    synced_once: bool,
4659}
4660
4661#[derive(Debug, Clone)]
4662struct OutboxOp {
4663    upsert: bool,
4664    table: String,
4665    row_id: String,
4666    base_version: Option<i64>,
4667    /// Schema-agnostic local form (§0): driver JSON values, encoded with
4668    /// the current codec at send time. A key's presence IS the §6.1 sparse
4669    /// presence set — both the overlay and the wire payload derive from it.
4670    values: Option<Map<String, Value>>,
4671}
4672
4673impl From<&OutboxOp> for CommitOperation {
4674    fn from(operation: &OutboxOp) -> Self {
4675        Self {
4676            table: operation.table.clone(),
4677            row_id: operation.row_id.clone(),
4678            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4679            base_version: operation.base_version,
4680            values: operation.values.clone(),
4681        }
4682    }
4683}
4684
4685#[derive(Debug, Clone)]
4686struct OutboxCommit {
4687    client_commit_id: String,
4688    ops: Vec<OutboxOp>,
4689}
4690
4691/// One commit's `(table, value keys)` per upsert operation — the classification
4692/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
4693/// can never disagree about which commits cannot re-encode.
4694fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4695    commit
4696        .ops
4697        .iter()
4698        .filter(|operation| operation.upsert)
4699        .map(|operation| {
4700            (
4701                operation.table.as_str(),
4702                operation
4703                    .values
4704                    .as_ref()
4705                    .map(|values| values.keys().map(String::as_str).collect())
4706                    .unwrap_or_default(),
4707            )
4708        })
4709        .collect()
4710}
4711
4712#[derive(Debug, Clone)]
4713struct CompiledLocalDataPurgeTarget {
4714    table: String,
4715    selectors: Vec<(String, Vec<String>)>,
4716}
4717
4718struct StoredCommitOutcomeRow {
4719    sequence: i64,
4720    client_commit_id: String,
4721    status: String,
4722    recorded_at_ms: i64,
4723    results_json: String,
4724    operations_json: Option<String>,
4725    resolution: String,
4726    resolved_at_ms: Option<i64>,
4727    replacement_client_commit_id: Option<String>,
4728}
4729
4730/// Section outcome distinguishing the §5.6 subscription-local fail-closed
4731/// path from a round-aborting failure (§1.4 rule 5).
4732enum SectionError {
4733    FailClosed,
4734    Abort(String, String),
4735}
4736
4737struct RequestMeta {
4738    pushed_ids: Vec<String>,
4739    /// Subscription id → the request carried `cursor < 0` and no resume
4740    /// token (§5.6 first-page detection: a *fresh* bootstrap).
4741    fresh: Vec<(String, bool)>,
4742    accept: u8,
4743    /// §6.1 splitBatch: outbox commits held back from THIS request because
4744    /// the running operation count reached the push cap — the next round
4745    /// pushes them (`sync_needed` stays set while any remain).
4746    deferred_commits: usize,
4747}
4748
4749#[derive(Default)]
4750struct ChangeAccumulator {
4751    /// None means table-wide; Some is the exact scoped domain.
4752    tables: BTreeMap<String, Option<BTreeSet<String>>>,
4753    windows: BTreeMap<(String, String), BTreeSet<String>>,
4754    status: bool,
4755    conflicts: bool,
4756    rejections: bool,
4757    outcomes: bool,
4758}
4759
4760impl ChangeAccumulator {
4761    fn table(&mut self, table: &str) {
4762        self.tables.insert(table.to_owned(), None);
4763    }
4764
4765    fn scope(&mut self, table: &str, key: String) {
4766        match self.tables.get_mut(table) {
4767            Some(None) => {}
4768            Some(Some(keys)) => {
4769                keys.insert(key);
4770            }
4771            None => {
4772                self.tables
4773                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
4774            }
4775        }
4776    }
4777
4778    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4779        self.windows
4780            .entry((base_key.to_owned(), table.to_owned()))
4781            .or_default()
4782            .insert(unit.to_owned());
4783    }
4784
4785    fn touched(&self) -> bool {
4786        !self.tables.is_empty()
4787            || !self.windows.is_empty()
4788            || self.status
4789            || self.conflicts
4790            || self.rejections
4791            || self.outcomes
4792    }
4793}
4794
4795/// §6.1: the server caps total operations per request (reference default
4796/// 500) and rejects the whole batch with `sync.too_many_operations`; the
4797/// client "splits and retries". Splitting happens at build time: commits are
4798/// included IN ORDER until the operation budget is spent, the rest wait for
4799/// the next round.
4800const PUSH_OPS_PER_REQUEST: usize = 500;
4801const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4802const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4803const MAX_LOCAL_PURGE_VALUES: usize = 128;
4804const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4805pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4806
4807pub struct SyncClient {
4808    #[cfg(feature = "bench-internals")]
4809    benchmark_phases: Recorder,
4810    conn: Connection,
4811    schema: ClientSchema,
4812    client_id: String,
4813    limits: ClientLimits,
4814    subs: Vec<Subscription>,
4815    outbox: Vec<OutboxCommit>,
4816    conflicts: Vec<ConflictRecord>,
4817    rejections: Vec<RejectionRecord>,
4818    schema_floor: Option<SchemaFloor>,
4819    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
4820    lease_state: Option<LeaseState>,
4821    /// §1.6: the schema-floor response stops syncing until an upgrade.
4822    stopped: bool,
4823    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
4824    upgrading: bool,
4825    /// §8.4 coalesced sync-needed signal.
4826    sync_needed: bool,
4827    /// §8.8: how sync rounds treat the realtime binding, and its exact
4828    /// availability state with the evidence behind a lost or refused one.
4829    realtime_policy: RealtimePolicy,
4830    realtime_state: RealtimeState,
4831    realtime_reason_code: Option<String>,
4832    realtime_retry_delay_ms: Option<u64>,
4833    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
4834    presence: HashMap<String, HashMap<String, PresencePeer>>,
4835    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
4836    /// host may pin it (conformance runs on a virtual clock).
4837    now_ms: Option<i64>,
4838    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
4839    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
4840    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
4841    encryption: crate::values::EncryptionConfig,
4842    /// Fail-closed host bootstrap gate. While set, command hosts permit only
4843    /// status/lifecycle inspection and an exact authorized local purge.
4844    security_preflight: bool,
4845    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
4846    /// means the feature is off; the descriptor write and the container orphan
4847    /// sweep still happen.
4848    previous_version: Option<PreviousVersionContextConfig>,
4849    /// Per-table primary-key upsert SQL, built once per (full table name) —
4850    /// the row write path runs per row during bootstrap (§5.6), so the SQL
4851    /// string (and, via `prepare_cached`, its compiled statement) is reused
4852    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
4853    /// schema reset (the column lists may have changed).
4854    insert_sql: RefCell<HashMap<String, String>>,
4855    /// §7.1 rebuild gate: true whenever the base tables or the outbox have
4856    /// diverged from the visible overlay since the last rebuild. Lets a
4857    /// no-op sync round skip the full base→visible copy.
4858    overlay_dirty: Cell<bool>,
4859    /// Test-only structural performance signal: response processing must not
4860    /// turn a batch of acknowledgements into one full overlay rebuild each.
4861    #[cfg(test)]
4862    overlay_rebuild_count: Cell<usize>,
4863    /// Test-only structural performance signal: outcome retention is enforced
4864    /// once per response rather than once per acknowledgement.
4865    #[cfg(test)]
4866    outcome_prune_count: Cell<usize>,
4867    /// Exact observer-transaction output drained by command/FFI hosts.
4868    progress: ProgressObserver,
4869    change_queue: VecDeque<ClientChangeBatch>,
4870    sync_intent_queue: VecDeque<SyncIntent>,
4871    /// Explicit exponential retry policy for transient transport failures.
4872    retry_delay_ms: u64,
4873    last_round: Option<DiagnosticLastRound>,
4874    last_change: Option<DiagnosticLastChange>,
4875    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
4876    query_failures: Vec<DiagnosticQueryFailure>,
4877}
4878
4879pub(crate) fn quote_ident(name: &str) -> String {
4880    format!("\"{}\"", name.replace('"', "\"\""))
4881}
4882/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
4883/// previous-version module reads and writes the SAME records as the client
4884/// instead of growing a parallel meta layer.
4885pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
4886    conn.query_row(
4887        "SELECT value FROM _syncular_meta WHERE key = ?1",
4888        rusqlite::params![key],
4889        |row| row.get::<_, String>(0),
4890    )
4891    .ok()
4892}
4893
4894pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
4895    let _ = conn.execute(
4896        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4897        rusqlite::params![key, value],
4898    );
4899}
4900
4901pub(crate) fn meta_delete(conn: &Connection, key: &str) {
4902    let _ = conn.execute(
4903        "DELETE FROM _syncular_meta WHERE key = ?1",
4904        rusqlite::params![key],
4905    );
4906}
4907
4908/// Seek by the existing key while retaining the exact legacy text match.
4909fn row_id_predicate(table: &TableSchema) -> String {
4910    let key = quote_ident(&table.primary_key);
4911    let text_match = format!("CAST({key} AS TEXT) = ?1");
4912    match table.columns[table.pk_index].ty {
4913        ColumnType::String | ColumnType::Json => {
4914            format!("{key} = ?1 AND {text_match}")
4915        }
4916        // Native columns have no declared SQLite affinity. Match the stored
4917        // integer bind type explicitly. Unary + removes expression affinity so
4918        // SQLite can seek the typeless key; the text check preserves exact IDs.
4919        ColumnType::Integer | ColumnType::Boolean => {
4920            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4921        }
4922        // REAL-to-text conversion can round distinct values to the same text.
4923        // Narrowing those matches by numeric equality would change behavior.
4924        _ => text_match,
4925    }
4926}
4927
4928fn is_local_operation_code_like(value: &str) -> bool {
4929    let bytes = value.as_bytes();
4930    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4931        && bytes
4932            .iter()
4933            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4934}
4935
4936fn base_table(name: &str) -> String {
4937    quote_ident(&format!("_syncular_base_{name}"))
4938}
4939
4940/// §4.8: a stable, server-opaque key for a window base — table + variable +
4941/// canonical fixed scopes. Two `set_window` calls with the same base
4942/// address the same registry rows.
4943/// §4.8 deferred eviction record: (sub id, table, effective scope map).
4944type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
4945
4946fn window_base_key(base: &WindowBase) -> String {
4947    format!(
4948        "{}\0{}\0{}",
4949        base.table,
4950        base.variable,
4951        canonical_scope_json(&base.fixed_scopes)
4952    )
4953}
4954
4955/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
4956fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
4957    let mut scopes = base.fixed_scopes.clone();
4958    scopes.retain(|(k, _)| k != &base.variable);
4959    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
4960    scopes
4961}
4962
4963/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
4964/// echoed not interpreted by the server, so the exact hash is client
4965/// convention; SHA-256 matches the SPEC's worked example.
4966fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
4967    let canonical = canonical_scope_json(&unit_scopes(base, unit));
4968    let digest = Sha256::digest(canonical.as_bytes());
4969    let hex = bytes_to_hex(&digest);
4970    format!("w:{}:{}", base.table, &hex[..16])
4971}
4972
4973fn visible_table(name: &str) -> String {
4974    quote_ident(name)
4975}
4976
4977const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
4978
4979/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
4980/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
4981fn is_synced_table_name(name: &str) -> bool {
4982    if name.starts_with("sqlite_") {
4983        return false;
4984    }
4985    if name.starts_with("_syncular_base_") {
4986        return true; // the base half of a synced table pair
4987    }
4988    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
4989    !name.starts_with("_syncular_")
4990}
4991
4992/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
4993fn blob_id_for(bytes: &[u8]) -> String {
4994    let digest = Sha256::digest(bytes);
4995    format!("sha256:{}", bytes_to_hex(&digest))
4996}
4997
4998/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
4999/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
5000/// per row on the §5.6 bootstrap path), scalars bind owned.
5001enum RowParam<'a> {
5002    Cell(&'a Option<ColumnValue>),
5003    Version(i64),
5004}
5005
5006impl rusqlite::ToSql for RowParam<'_> {
5007    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5008        Ok(match self {
5009            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5010            RowParam::Cell(cell) => match cell {
5011                None => ToSqlOutput::Owned(SqlValue::Null),
5012                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5013                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5014                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5015                Some(ColumnValue::Boolean(b)) => {
5016                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5017                }
5018                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5019                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5020                }
5021                // §5.10: crdt bytes store as BLOB, like bytes.
5022                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5023                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
5024                }
5025            },
5026        })
5027    }
5028}
5029
5030// Retain a typed primary key across the current import chunk without copying row payloads.
5031fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5032    match param.to_sql().map_err(|error| error.to_string())? {
5033        ToSqlOutput::Owned(value) => Ok(value),
5034        ToSqlOutput::Borrowed(value) => {
5035            SqlValue::try_from(value).map_err(|error| error.to_string())
5036        }
5037        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5038    }
5039}
5040
5041/// §5.3 image cell → bind parameter, strict per the declared column type
5042/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
5043/// nullable). Mismatches are image-producer violations. Returns the cell
5044/// borrowed when the stored representation already matches what the row
5045/// codec would write, or the normalized scalar (boolean → 0/1, float from
5046/// INTEGER → REAL) otherwise — the local write is byte-identical to the
5047/// old convert-then-insert path without allocating per cell.
5048fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5049    use ssp2::segment::ColumnType;
5050    let mismatch = || {
5051        Err(format!(
5052            "image column {:?} holds a value of the wrong type",
5053            column.name
5054        ))
5055    };
5056    match value {
5057        ValueRef::Null => {
5058            if !column.nullable {
5059                return Err(format!(
5060                    "image column {:?} is NULL but not nullable",
5061                    column.name
5062                ));
5063            }
5064            Ok(ToSqlOutput::Owned(SqlValue::Null))
5065        }
5066        ValueRef::Integer(i) => match column.ty {
5067            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5068            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5069            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5070            _ => mismatch(),
5071        },
5072        ValueRef::Real(_) => match column.ty {
5073            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5074            _ => mismatch(),
5075        },
5076        ValueRef::Text(t) => {
5077            std::str::from_utf8(t)
5078                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5079            match column.ty {
5080                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5081                    Ok(ToSqlOutput::Borrowed(value))
5082                }
5083                _ => mismatch(),
5084            }
5085        }
5086        ValueRef::Blob(_) => match column.ty {
5087            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
5088            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5089            _ => mismatch(),
5090        },
5091    }
5092}
5093
5094pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5095    use rusqlite::types::ValueRef;
5096    match value {
5097        ValueRef::Null => Value::Null,
5098        ValueRef::Integer(i) => match column.ty {
5099            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5100            ssp2::segment::ColumnType::Float => {
5101                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5102            }
5103            _ => Value::from(i),
5104        },
5105        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5106        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5107        ValueRef::Blob(b) => {
5108            let mut map = Map::new();
5109            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5110            Value::Object(map)
5111        }
5112    }
5113}
5114
5115/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
5116/// Objects are accepted in lossless `{"$bytes": hex}` and
5117/// `{"$bigint": decimal}` envelope forms.
5118fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5119    Ok(match value {
5120        Value::Null => SqlValue::Null,
5121        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5122        Value::Number(n) => {
5123            if let Some(i) = n.as_i64() {
5124                SqlValue::Integer(i)
5125            } else if let Some(f) = n.as_f64() {
5126                SqlValue::Real(f)
5127            } else {
5128                return Err(format!("query param number {n} is out of range"));
5129            }
5130        }
5131        Value::String(s) => SqlValue::Text(s.clone()),
5132        Value::Object(_) => {
5133            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5134                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5135            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5136                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5137                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5138                })?)
5139            } else {
5140                return Err(
5141                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5142                        .to_owned(),
5143                );
5144            }
5145        }
5146        Value::Array(_) => return Err("query array params are not supported".to_owned()),
5147    })
5148}
5149
5150/// Map a rusqlite value with no schema column to consult (arbitrary query
5151/// output): integers/reals/text pass through by stored affinity, blobs ride
5152/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
5153/// schema column type to recover booleans/floats/json.
5154fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5155    use rusqlite::types::ValueRef;
5156    match value {
5157        ValueRef::Null => Value::Null,
5158        ValueRef::Integer(i) => {
5159            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
5160            // ordinary UI-sized integers ergonomic and envelope only values
5161            // beyond JavaScript's safe range.
5162            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5163            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5164                Value::from(i)
5165            } else {
5166                let mut map = Map::new();
5167                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5168                Value::Object(map)
5169            }
5170        }
5171        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5172        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5173        ValueRef::Blob(b) => {
5174            let mut map = Map::new();
5175            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5176            Value::Object(map)
5177        }
5178    }
5179}
5180
5181/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
5182fn query_connection(
5183    conn: &Connection,
5184    sql: &str,
5185    params: &[QueryValue],
5186) -> Result<Vec<QueryRow>, QueryReadFailure> {
5187    crate::query_guard::assert_read_only_query(sql)?;
5188    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5189    let bound: Vec<SqlValue> = params
5190        .iter()
5191        .map(json_param_to_sql)
5192        .collect::<Result<_, _>>()?;
5193    let mut stmt = conn.prepare(&lowered_sql)?;
5194    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5195    let bound_refs: Vec<&dyn rusqlite::ToSql> =
5196        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5197    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5198    let mut out = Vec::new();
5199    while let Some(row) = sql_rows.next()? {
5200        let mut record = Map::new();
5201        for (i, name) in column_names.iter().enumerate() {
5202            let value = row.get_ref(i)?;
5203            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5204        }
5205        out.push(record);
5206    }
5207    Ok(out)
5208}
5209
5210fn persisted_window_state(
5211    conn: &Connection,
5212    base: &WindowBase,
5213) -> Result<WindowState, QueryReadFailure> {
5214    let mut stmt = conn.prepare(
5215        "SELECT windows.unit, subscriptions.state_json
5216           FROM _syncular_windows AS windows
5217           JOIN _syncular_subscriptions AS subscriptions
5218             ON subscriptions.id = windows.sub_id
5219          WHERE windows.base = ?1
5220          ORDER BY windows.unit ASC",
5221    )?;
5222    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5223        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5224    })?;
5225    let mut units = Vec::new();
5226    let mut pending = Vec::new();
5227    for row in rows {
5228        let (unit, raw) = row?;
5229        let state: Value = serde_json::from_str(&raw)
5230            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5231        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5232            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5233            || state
5234                .get("bootstrapState")
5235                .is_some_and(|value| !value.is_null());
5236        if is_pending {
5237            pending.push(unit.clone());
5238        }
5239        units.push(unit);
5240    }
5241    Ok(WindowState { units, pending })
5242}
5243
5244fn snapshot_connection(
5245    conn: &Connection,
5246    sql: &str,
5247    params: &[Value],
5248    coverage: &[WindowCoverage],
5249) -> Result<QuerySnapshot, QueryReadFailure> {
5250    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5251    let result = (|| {
5252        let revision = conn
5253            .query_row(
5254                "SELECT value FROM _syncular_meta WHERE key = ?1",
5255                rusqlite::params![LOCAL_REVISION_KEY],
5256                |row| row.get::<_, String>(0),
5257            )
5258            .ok()
5259            .and_then(|value| value.parse::<u64>().ok())
5260            .unwrap_or(0);
5261        let rows = query_connection(conn, sql, params)?;
5262        let mut pending = Vec::new();
5263        let mut missing = Vec::new();
5264        for requested in coverage {
5265            let base_key = window_base_key(&requested.base);
5266            let state = persisted_window_state(conn, &requested.base)?;
5267            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5268                let reference = WindowUnitRef {
5269                    base_key: base_key.clone(),
5270                    unit: unit.clone(),
5271                };
5272                if !state.units.iter().any(|held| held == &unit) {
5273                    missing.push(reference);
5274                } else if state.pending.iter().any(|held| held == &unit) {
5275                    pending.push(reference);
5276                }
5277            }
5278        }
5279        Ok(QuerySnapshot {
5280            revision: revision.to_string(),
5281            rows,
5282            coverage: CoverageSnapshot {
5283                complete: pending.is_empty() && missing.is_empty(),
5284                pending,
5285                missing,
5286            },
5287        })
5288    })();
5289    match result {
5290        Ok(snapshot) => {
5291            conn.execute_batch("RELEASE syncular_snapshot_read")?;
5292            Ok(snapshot)
5293        }
5294        Err(error) => {
5295            let _ = conn.execute_batch(
5296                "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
5297            );
5298            Err(error)
5299        }
5300    }
5301}
5302
5303/// A long-lived read-only SQLite sidecar for latency-critical native views.
5304/// Network rounds stay serialized on the mutable core owner, while atomic
5305/// query snapshots use this independent connection and therefore never queue
5306/// behind HTTP/WebSocket latency.
5307pub struct FileQuerySnapshotReader {
5308    path: String,
5309    conn: Option<Connection>,
5310}
5311
5312impl FileQuerySnapshotReader {
5313    #[must_use]
5314    pub fn new(path: impl Into<String>) -> Self {
5315        Self {
5316            path: path.into(),
5317            conn: None,
5318        }
5319    }
5320
5321    fn connection(&mut self) -> Result<&Connection, String> {
5322        if self.conn.is_none() {
5323            let conn = Connection::open_with_flags(
5324                &self.path,
5325                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5326            )
5327            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5328            conn.busy_timeout(std::time::Duration::from_millis(250))
5329                .map_err(|error| error.to_string())?;
5330            self.conn = Some(conn);
5331        }
5332        self.conn
5333            .as_ref()
5334            .ok_or_else(|| "read sidecar connection missing".to_owned())
5335    }
5336
5337    /// Hosts report an owned failure, and the first success after it, to the
5338    /// owning core through [`SyncClient::record_query_read`] (§7.6).
5339    pub fn query_snapshot(
5340        &mut self,
5341        sql: &str,
5342        params: &[Value],
5343        coverage: &[WindowCoverage],
5344    ) -> Result<QuerySnapshot, QueryReadFailure> {
5345        snapshot_connection(self.connection()?, sql, params, coverage)
5346    }
5347}
5348
5349impl SyncClient {
5350    pub fn new_with_identity(
5351        client_id: Option<String>,
5352        schema_json: &Value,
5353        limits: ClientLimits,
5354    ) -> Result<Self, String> {
5355        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5356        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5357        Self::with_connection(resolved, schema_json, limits, conn)
5358    }
5359
5360    pub fn new(
5361        client_id: String,
5362        schema_json: &Value,
5363        limits: ClientLimits,
5364    ) -> Result<Self, String> {
5365        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5366        Self::with_connection(client_id, schema_json, limits, conn)
5367    }
5368
5369    /// Build a client backed by an on-disk SQLite database at `path` — the
5370    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
5371    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
5372    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
5373    /// of the command router's dependency set (the router only holds a path).
5374    pub fn open_path(
5375        client_id: String,
5376        schema_json: &Value,
5377        limits: ClientLimits,
5378        path: &str,
5379    ) -> Result<Self, String> {
5380        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5381        Self::with_connection(client_id, schema_json, limits, conn)
5382    }
5383
5384    pub fn open_path_with_identity(
5385        client_id: Option<String>,
5386        schema_json: &Value,
5387        limits: ClientLimits,
5388        path: &str,
5389    ) -> Result<Self, String> {
5390        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5391        // File-backed native clients use an independent read connection for
5392        // latency-critical snapshots. WAL is SQLite's intended reader/writer
5393        // concurrency mode: a view read never holds a rollback-journal lock
5394        // that delays the mutable client's next commit, and a short busy
5395        // timeout absorbs the tiny checkpoint/schema-lock windows.
5396        conn.busy_timeout(std::time::Duration::from_millis(250))
5397            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5398        conn.pragma_update(None, "journal_mode", "WAL")
5399            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5400        let persisted = conn
5401            .query_row(
5402                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5403                [],
5404                |row| row.get::<_, String>(0),
5405            )
5406            .ok();
5407        let resolved = persisted
5408            .clone()
5409            .or(client_id.clone())
5410            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5411        if let (Some(existing), Some(requested)) = (persisted, client_id) {
5412            if existing != requested {
5413                return Err(format!(
5414                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5415                ));
5416            }
5417        }
5418        Self::with_connection(resolved, schema_json, limits, conn)
5419    }
5420
5421    #[must_use]
5422    pub fn client_id(&self) -> &str {
5423        &self.client_id
5424    }
5425
5426    /// Build a client over a caller-supplied rusqlite connection — the seam a
5427    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
5428    /// with an on-disk database (`Connection::open(path)`) rather than the
5429    /// default `:memory:`. The connection MUST be fresh (no pre-existing
5430    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
5431    /// the same file across process restarts reuses the persisted rows.
5432    pub fn with_connection(
5433        client_id: String,
5434        schema_json: &Value,
5435        limits: ClientLimits,
5436        conn: Connection,
5437    ) -> Result<Self, String> {
5438        if limits.outcome_retention_max_entries == Some(0) {
5439            return Err(
5440                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5441            );
5442        }
5443        let schema = parse_schema_json(schema_json)?;
5444        // RFC 0005 D8: resolve the previous-version config before the opening
5445        // reset runs — the capture happens inside it. Bad bounds fail loud.
5446        let previous_version = match limits.previous_version_context {
5447            Some(config) => {
5448                config.validate()?;
5449                Some(config)
5450            }
5451            None => None,
5452        };
5453        let mut client = SyncClient {
5454            #[cfg(feature = "bench-internals")]
5455            benchmark_phases: Recorder::default(),
5456            conn,
5457            schema,
5458            client_id,
5459            limits,
5460            subs: Vec::new(),
5461            outbox: Vec::new(),
5462            conflicts: Vec::new(),
5463            rejections: Vec::new(),
5464            schema_floor: None,
5465            lease_state: None,
5466            stopped: false,
5467            upgrading: false,
5468            sync_needed: false,
5469            realtime_policy: RealtimePolicy::Optional,
5470            realtime_state: RealtimeState::Disconnected,
5471            realtime_reason_code: None,
5472            realtime_retry_delay_ms: None,
5473            presence: HashMap::new(),
5474            now_ms: None,
5475            encryption: crate::values::EncryptionConfig::default(),
5476            security_preflight: false,
5477            previous_version,
5478            insert_sql: RefCell::new(HashMap::new()),
5479            overlay_dirty: Cell::new(false),
5480            #[cfg(test)]
5481            overlay_rebuild_count: Cell::new(0),
5482            #[cfg(test)]
5483            outcome_prune_count: Cell::new(0),
5484            progress: ProgressObserver::default(),
5485            change_queue: VecDeque::new(),
5486            sync_intent_queue: VecDeque::new(),
5487            retry_delay_ms: 250,
5488            last_round: None,
5489            last_change: None,
5490            query_failures: Vec::new(),
5491        };
5492        // The row write path leans on the prepared-statement cache (two
5493        // insert statements per synced table, plus the bookkeeping
5494        // statements); size it so a multi-table schema never thrashes.
5495        client
5496            .conn
5497            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5498        // Protected bookkeeping must exist before inspecting the persisted
5499        // schema marker. New app indexes may reference columns that only
5500        // exist after a version-bump reset.
5501        client.create_bookkeeping_tables()?;
5502        match client.get_meta(CLIENT_ID_KEY) {
5503            Some(existing) if existing != client.client_id => {
5504                return Err(format!(
5505                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5506                    client.client_id
5507                ));
5508            }
5509            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5510            _ => {}
5511        }
5512        client.restore_persisted_state()?;
5513        let marker = client
5514            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5515            .and_then(|value| value.parse::<i32>().ok());
5516        match marker {
5517            None => {
5518                client.create_synced_tables()?;
5519                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5520                // D1: the descriptor is written beside every marker write.
5521                set_local_schema_descriptor(&client.conn, &client.schema);
5522            }
5523            Some(version) if version == client.schema.version => {
5524                client.create_synced_tables()?;
5525                // RFC 0005 D1: the same-version open backfills the descriptor
5526                // for a database first opened by an unaware binary, so the
5527                // NEXT bump can capture without needing a schema bump first.
5528                set_local_schema_descriptor(&client.conn, &client.schema);
5529            }
5530            Some(_) => client.run_schema_reset()?,
5531        }
5532        // RFC 0005 D9: the reset sweep cannot see a container whose capture
5533        // committed in its OWN file while the replica's savepoint never
5534        // released — a crash at that seam leaves a container beside a matching
5535        // OLD marker, and the next open at that version runs no reset. Discard
5536        // it before anything can read it. No-op when no container exists.
5537        client.reconcile_previous_version_at_boot()?;
5538        client.clear_satisfied_persisted_schema_floor();
5539        client.prune_unknown_subscriptions()?;
5540        if marker == Some(client.schema.version) && !client.outbox.is_empty() {
5541            // Reconstruct the visible optimistic overlay from the durable base
5542            // plus outbox instead of trusting a process-interrupted mirror.
5543            client.overlay_dirty.set(true);
5544            client.rebuild_overlay();
5545        }
5546        // Every persisted active subscription needs one catch-up pull on open:
5547        // realtime only covers changes after connection, while an idempotent
5548        // setWindow correctly creates no fresh command effect. Pending outbox
5549        // work has the same restart requirement. The core owns this intent so
5550        // native hosts never poll or require an application-issued sync().
5551        client.enqueue_startup_sync_if_needed()?;
5552        Ok(client)
5553    }
5554
5555    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
5556    /// against this instead of system time (conformance virtual clock).
5557    pub fn set_now_ms(&mut self, now_ms: i64) {
5558        self.now_ms = Some(now_ms);
5559    }
5560
5561    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
5562    /// The command router parses these from the `create` command's
5563    /// `encryption` config (keys as `{$bytes: hex}`).
5564    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5565        self.encryption = encryption;
5566    }
5567
5568    #[must_use]
5569    pub fn security_lifecycle(&self) -> &'static str {
5570        if self.security_preflight {
5571            "preflight"
5572        } else {
5573            "active"
5574        }
5575    }
5576
5577    #[must_use]
5578    pub fn security_preflight(&self) -> bool {
5579        self.security_preflight
5580    }
5581
5582    /// Quarantine this replica: enter the fail-closed gate, release all
5583    /// core-owned key material, and record the gate in the database so
5584    /// reopening re-enters preflight until `activate_security` clears it.
5585    ///
5586    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
5587    /// which must NOT leave that durable mark.
5588    pub fn begin_security_preflight(&mut self) {
5589        self.seal_security_on_teardown();
5590        // Persist the quarantine so it survives handle teardown and restart:
5591        // reopening this replica re-enters preflight until activation clears it.
5592        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5593    }
5594
5595    /// Release core-owned key material as a host tears the client down,
5596    /// WITHOUT recording a quarantine.
5597    ///
5598    /// Shutting an activated client down is not a quarantine event. Persisting
5599    /// the gate here marks every cleanly closed replica as pending, and the
5600    /// next plain `create` is then refused permanently: the reopen path
5601    /// restores the flag from the marker and the create guard rejects it. The
5602    /// in-memory flag still closes the gate for anything still holding this
5603    /// instance, which is all a teardown barrier needs.
5604    pub fn seal_security_on_teardown(&mut self) {
5605        self.security_preflight = true;
5606        self.encryption = crate::values::EncryptionConfig::default();
5607        self.sync_intent_queue.clear();
5608    }
5609
5610    /// Install the post-authentication keyring and release the host loop.
5611    pub fn activate_security(
5612        &mut self,
5613        encryption: crate::values::EncryptionConfig,
5614    ) -> Result<(), String> {
5615        if !self.security_preflight {
5616            return Err(
5617                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5618            );
5619        }
5620        self.enqueue_startup_sync_if_needed()?;
5621        self.encryption = encryption;
5622        self.security_preflight = false;
5623        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5624        Ok(())
5625    }
5626
5627    fn clock_now_ms(&self) -> i64 {
5628        self.now_ms.unwrap_or_else(|| {
5629            std::time::SystemTime::now()
5630                .duration_since(std::time::UNIX_EPOCH)
5631                .map(|d| d.as_millis() as i64)
5632                .unwrap_or(0)
5633        })
5634    }
5635
5636    fn create_bookkeeping_tables(&self) -> Result<(), String> {
5637        // Durable client bookkeeping (outbox + subscription + meta).
5638        self.conn
5639            .execute_batch(
5640                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5641                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5642                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5643                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5644                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5645                   client_commit_id TEXT NOT NULL UNIQUE,
5646                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5647                   recorded_at_ms INTEGER NOT NULL,
5648                   results_json TEXT NOT NULL,
5649                   operations_json TEXT,
5650                   resolution TEXT NOT NULL DEFAULT 'active'
5651                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5652                   resolved_at_ms INTEGER,
5653                   replacement_client_commit_id TEXT);
5654                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5655                   ON _syncular_commit_outcomes(resolution, seq);
5656                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5657                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5658                 CREATE TABLE IF NOT EXISTS _syncular_meta (
5659                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
5660                 CREATE TABLE IF NOT EXISTS _syncular_windows (
5661                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5662                   PRIMARY KEY (base, unit));
5663                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5664                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5665                   effective_scopes TEXT NOT NULL);
5666                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5667                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5668                   PRIMARY KEY(commit_id, blob_id));
5669                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5670                   ON _syncular_blob_commit_refs(blob_id);",
5671            )
5672            .map_err(|e| e.to_string())?;
5673        // Migrate an outcome journal created before failed aggregate
5674        // envelopes were retained. Historical rows intentionally stay NULL.
5675        let _ = self
5676            .conn
5677            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5678        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5679            self.set_meta(LOCAL_REVISION_KEY, "0");
5680        }
5681        // §5.9.7 blob cache. Mutable upload state stays off the body row so
5682        // completing a large upload does not rewrite its BLOB record.
5683        if self.schema_has_blobs() {
5684            let exists = self
5685                .conn
5686                .query_row(
5687                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5688                    [],
5689                    |row| row.get::<_, bool>(0),
5690                )
5691                .map_err(|e| e.to_string())?;
5692            if exists {
5693                let mut statement = self
5694                    .conn
5695                    .prepare("PRAGMA table_info(_syncular_blobs)")
5696                    .map_err(|e| e.to_string())?;
5697                let mut columns = statement
5698                    .query_map([], |row| row.get::<_, String>(1))
5699                    .map_err(|e| e.to_string())?
5700                    .collect::<Result<Vec<_>, _>>()
5701                    .map_err(|e| e.to_string())?;
5702                columns.sort();
5703                let mut expected = vec![
5704                    "blob_id",
5705                    "byte_length",
5706                    "bytes",
5707                    "created_at_ms",
5708                    "media_type",
5709                ];
5710                expected.sort_unstable();
5711                if columns != expected {
5712                    return Err(
5713                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5714                    );
5715                }
5716            } else {
5717                self.conn
5718                    .execute_batch(
5719                        "CREATE TABLE _syncular_blobs(
5720                           blob_id TEXT PRIMARY KEY,
5721                           bytes BLOB NOT NULL,
5722                           byte_length INTEGER NOT NULL,
5723                           media_type TEXT,
5724                           created_at_ms INTEGER NOT NULL);",
5725                    )
5726                    .map_err(|e| e.to_string())?;
5727            }
5728            self.conn
5729                .execute_batch(
5730                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5731                       blob_id TEXT PRIMARY KEY,
5732                       media_type TEXT,
5733                       created_at_ms INTEGER NOT NULL);",
5734                )
5735                .map_err(|e| e.to_string())?;
5736        }
5737        Ok(())
5738    }
5739
5740    /// True iff any synced table declares a `blob_ref` column (§5.9).
5741    fn schema_has_blobs(&self) -> bool {
5742        self.schema
5743            .tables
5744            .iter()
5745            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5746    }
5747
5748    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
5749
5750    fn get_meta(&self, key: &str) -> Option<String> {
5751        meta_get(&self.conn, key)
5752    }
5753
5754    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5755        self.conn
5756            .query_row(
5757                "SELECT value FROM _syncular_meta WHERE key = ?1",
5758                rusqlite::params![key],
5759                |row| row.get::<_, String>(0),
5760            )
5761            .optional()
5762            .map_err(|_| {
5763                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5764            })
5765    }
5766
5767    fn set_meta(&self, key: &str, value: &str) {
5768        meta_set(&self.conn, key, value);
5769    }
5770
5771    fn delete_meta(&self, key: &str) {
5772        meta_delete(&self.conn, key);
5773    }
5774
5775    fn restore_persisted_state(&mut self) -> Result<(), String> {
5776        self.subs = {
5777            let mut stmt = self
5778                .conn
5779                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5780                .map_err(|error| error.to_string())?;
5781            let rows = stmt
5782                .query_map([], |row| {
5783                    Ok((
5784                        row.get::<_, String>(0)?,
5785                        row.get::<_, String>(1)?,
5786                        row.get::<_, String>(2)?,
5787                    ))
5788                })
5789                .map_err(|error| error.to_string())?;
5790            let mut subscriptions = Vec::new();
5791            for row in rows {
5792                let (id, table, raw) = row.map_err(|error| error.to_string())?;
5793                let state: Value = serde_json::from_str(&raw)
5794                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5795                let requested = json_to_scope_map(
5796                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
5797                )?;
5798                let effective = state
5799                    .get("effectiveScopes")
5800                    .filter(|value| !value.is_null())
5801                    .map(json_to_scope_map)
5802                    .transpose()?;
5803                subscriptions.push(Subscription {
5804                    id,
5805                    table,
5806                    requested,
5807                    params: state
5808                        .get("params")
5809                        .and_then(Value::as_str)
5810                        .map(str::to_owned),
5811                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5812                    bootstrap_state: state
5813                        .get("bootstrapState")
5814                        .and_then(Value::as_str)
5815                        .map(str::to_owned),
5816                    state: SubState::parse(
5817                        state
5818                            .get("status")
5819                            .and_then(Value::as_str)
5820                            .unwrap_or("active"),
5821                    ),
5822                    reason_code: state
5823                        .get("reasonCode")
5824                        .and_then(Value::as_str)
5825                        .map(str::to_owned),
5826                    effective,
5827                    synced_once: state
5828                        .get("syncedOnce")
5829                        .and_then(Value::as_bool)
5830                        .unwrap_or(false),
5831                });
5832            }
5833            subscriptions
5834        };
5835
5836        self.outbox = {
5837            let mut stmt = self
5838                .conn
5839                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5840                .map_err(|error| error.to_string())?;
5841            let rows = stmt
5842                .query_map([], |row| {
5843                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5844                })
5845                .map_err(|error| error.to_string())?;
5846            let mut commits = Vec::new();
5847            for row in rows {
5848                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5849                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5850                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
5851                })?;
5852                let mut ops = Vec::with_capacity(entries.len());
5853                for entry in entries {
5854                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5855                    ops.push(OutboxOp {
5856                        upsert: op == "upsert",
5857                        table: entry
5858                            .get("table")
5859                            .and_then(Value::as_str)
5860                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5861                            .to_owned(),
5862                        row_id: entry
5863                            .get("rowId")
5864                            .and_then(Value::as_str)
5865                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5866                            .to_owned(),
5867                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
5868                        // A legacy entry's `changedFields` key is ignored:
5869                        // the values map is the presence set (§6.1).
5870                        values: entry.get("values").and_then(Value::as_object).cloned(),
5871                    });
5872                }
5873                commits.push(OutboxCommit {
5874                    client_commit_id,
5875                    ops,
5876                });
5877            }
5878            commits
5879        };
5880
5881        self.prune_commit_outcomes()?;
5882        let active = self.commit_outcomes(CommitOutcomeQuery {
5883            active_only: true,
5884            ..CommitOutcomeQuery::default()
5885        })?;
5886        self.conflicts = active
5887            .iter()
5888            .flat_map(|outcome| outcome.results.iter())
5889            .filter_map(|result| match result {
5890                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5891                _ => None,
5892            })
5893            .collect();
5894        self.rejections = active
5895            .iter()
5896            .flat_map(|outcome| outcome.results.iter())
5897            .filter_map(|result| match result {
5898                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5899                _ => None,
5900            })
5901            .collect();
5902
5903        self.lease_state = self
5904            .get_meta(LEASE_STATE_KEY)
5905            .map(|raw| serde_json::from_str(&raw))
5906            .transpose()
5907            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5908        self.schema_floor = self
5909            .get_meta(SCHEMA_FLOOR_KEY)
5910            .map(|raw| serde_json::from_str(&raw))
5911            .transpose()
5912            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5913        self.stopped = self.schema_floor.is_some();
5914        // A replica left in unactivated preflight reopens gated: the quarantine
5915        // decision persists with the data across handle teardown and restart.
5916        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5917            self.security_preflight = true;
5918        }
5919        Ok(())
5920    }
5921
5922    /// Remove registrations for tables the running schema no longer knows.
5923    /// Otherwise the server rejects every pull with `sync.unknown_table`.
5924    fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5925        let valid_tables: BTreeSet<String> = self
5926            .schema
5927            .tables
5928            .iter()
5929            .map(|table| table.name.clone())
5930            .collect();
5931        let stale_ids: Vec<String> = self
5932            .subs
5933            .iter()
5934            .filter(|sub| !valid_tables.contains(&sub.table))
5935            .map(|sub| sub.id.clone())
5936            .collect();
5937        for id in &stale_ids {
5938            self.conn
5939                .execute(
5940                    "DELETE FROM _syncular_windows WHERE sub_id = ?1",
5941                    rusqlite::params![id],
5942                )
5943                .map_err(|error| error.to_string())?;
5944            self.conn
5945                .execute(
5946                    "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5947                    rusqlite::params![id],
5948                )
5949                .map_err(|error| error.to_string())?;
5950            self.conn
5951                .execute(
5952                    "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5953                    rusqlite::params![id],
5954                )
5955                .map_err(|error| error.to_string())?;
5956        }
5957        self.subs.retain(|sub| valid_tables.contains(&sub.table));
5958        Ok(())
5959    }
5960
5961    #[must_use]
5962    pub fn local_revision(&self) -> u64 {
5963        self.get_meta(LOCAL_REVISION_KEY)
5964            .and_then(|value| value.parse().ok())
5965            .unwrap_or(0)
5966    }
5967
5968    #[must_use]
5969    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
5970        SyncStatusSnapshot {
5971            current_schema_version: self.schema.version,
5972            outbox: self.outbox.len(),
5973            upgrading: self.upgrading,
5974            lease_state: self.lease_state.clone(),
5975            schema_floor: self.schema_floor.clone(),
5976            sync_needed: self.sync_needed,
5977            previous_version_context: self.previous_version_status(),
5978        }
5979    }
5980
5981    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
5982    /// rollback precondition), read without creating the file.
5983    fn previous_version_status(&self) -> PreviousVersionStatus {
5984        let Some(replica_path) = self.previous_version_replica_path() else {
5985            return PreviousVersionStatus {
5986                present: false,
5987                created_at_ms: None,
5988            };
5989        };
5990        match crate::previous_version::read_previous_version_record(&replica_path) {
5991            Ok(Some(record)) => PreviousVersionStatus {
5992                present: true,
5993                created_at_ms: Some(record.created_at_ms),
5994            },
5995            _ => PreviousVersionStatus {
5996                present: std::path::Path::new(
5997                    &crate::previous_version::previous_version_container_path(&replica_path),
5998                )
5999                .exists(),
6000                created_at_ms: None,
6001            },
6002        }
6003    }
6004
6005    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
6006    /// is always `previousVersion`; the container never makes
6007    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
6008    /// revocation, coverage completion and the TTL DISCARD the container and
6009    /// close the read with their own reason.
6010    pub fn previous_version_snapshot(
6011        &self,
6012        spec: &PreviousVersionReadSpec,
6013    ) -> Result<PreviousVersionSnapshot, String> {
6014        let now_ms = self.clock_now_ms();
6015        crate::previous_version::previous_version_snapshot(
6016            &self.conn,
6017            self.previous_version_replica_path().as_deref(),
6018            self.previous_version.as_ref(),
6019            PreviousVersionLifecycle {
6020                lease_inactive: self.previous_version_lease_inactive(now_ms),
6021                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6022                coverage_complete: self.previous_version_coverage_complete(),
6023            },
6024            now_ms,
6025            self.schema.version,
6026            spec,
6027        )
6028    }
6029
6030    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
6031    #[must_use]
6032    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6033        crate::previous_version::stored_previous_version_audit(&self.conn)
6034    }
6035
6036    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
6037    /// and both metadata records, and report whether anything was present.
6038    /// Idempotent: absence is a successful no-op.
6039    pub fn previous_version_discard(
6040        &self,
6041    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6042        self.drop_previous_version()
6043    }
6044
6045    /// The module's discard has no failure path (it removes the file even when
6046    /// the container cannot be opened), so the only error a caller could ever
6047    /// see is a bug; report absence rather than claiming a discard happened.
6048    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6049        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6050            present: false,
6051            discarded: false,
6052        };
6053        let Some(replica_path) = self.previous_version_replica_path() else {
6054            return absent;
6055        };
6056        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6057            .unwrap_or(absent)
6058    }
6059
6060    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
6061    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6062        let Some(lease) = &self.lease_state else {
6063            return false;
6064        };
6065        if lease.error_code.is_some() {
6066            return true;
6067        }
6068        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6069    }
6070
6071    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
6072    /// has `cursor >= 0` and no resume token. An empty active set is not
6073    /// completion (mirrors the TS predicate exactly).
6074    fn previous_version_coverage_complete(&self) -> bool {
6075        let active = self
6076            .subs
6077            .iter()
6078            .filter(|sub| sub.state == SubState::Active)
6079            .collect::<Vec<_>>();
6080        if active.is_empty() {
6081            return false;
6082        }
6083        active
6084            .iter()
6085            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6086    }
6087
6088    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
6089    /// apply. A container that no longer has a reason to exist is discarded so
6090    /// the rows do not outlive the window they were kept for.
6091    fn previous_version_lifetime_check(&self) {
6092        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6093            return;
6094        };
6095        let now_ms = self.clock_now_ms();
6096        if self.previous_version_lease_inactive(now_ms) {
6097            let _ = self.drop_previous_version();
6098            return;
6099        }
6100        let Some(replica_path) = self.previous_version_replica_path() else {
6101            return;
6102        };
6103        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6104        else {
6105            return;
6106        };
6107        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6108            let _ = self.drop_previous_version();
6109            return;
6110        }
6111        if now_ms - record.created_at_ms > config.max_age_ms {
6112            let _ = self.drop_previous_version();
6113            return;
6114        }
6115        if self.previous_version_coverage_complete() {
6116            let _ = self.drop_previous_version();
6117        }
6118    }
6119
6120    pub fn diagnostics_snapshot(
6121        &self,
6122        request: &ClientDiagnosticsRequest,
6123    ) -> Result<ClientDiagnosticsSnapshot, String> {
6124        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6125            return Err(format!(
6126                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6127            ));
6128        }
6129        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6130        for sub in &self.subs {
6131            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6132            let complete =
6133                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6134            let state = match sub.state {
6135                SubState::Revoked => "revoked",
6136                SubState::Failed => "failed",
6137                SubState::Active if reset => "reset",
6138                SubState::Active if complete => "complete",
6139                SubState::Active => "bootstrapping",
6140            };
6141            subscriptions.insert(
6142                sub.id.clone(),
6143                DiagnosticSubscription {
6144                    id: sub.id.clone(),
6145                    table: sub.table.clone(),
6146                    state: state.to_owned(),
6147                    complete,
6148                    cursor: Some(sub.cursor),
6149                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6150                },
6151            );
6152        }
6153        for expected in &request.expected_subscriptions {
6154            if expected.id.is_empty() || expected.table.is_empty() {
6155                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6156            }
6157            if subscriptions
6158                .get(&expected.id)
6159                .is_some_and(|registered| registered.table != expected.table)
6160            {
6161                subscriptions.insert(
6162                    expected.id.clone(),
6163                    DiagnosticSubscription {
6164                        id: expected.id.clone(),
6165                        table: expected.table.clone(),
6166                        state: "failed".to_owned(),
6167                        complete: false,
6168                        cursor: None,
6169                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6170                    },
6171                );
6172            } else {
6173                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6174                    DiagnosticSubscription {
6175                        id: expected.id.clone(),
6176                        table: expected.table.clone(),
6177                        state: "unregistered".to_owned(),
6178                        complete: false,
6179                        cursor: None,
6180                        reason_code: None,
6181                    }
6182                });
6183            }
6184        }
6185        let mut ordered_subscriptions = Vec::new();
6186        let mut included = BTreeSet::new();
6187        for expected in &request.expected_subscriptions {
6188            if included.insert(expected.id.clone()) {
6189                if let Some(subscription) = subscriptions.get(&expected.id) {
6190                    ordered_subscriptions.push(subscription.clone());
6191                }
6192            }
6193        }
6194        for (id, subscription) in &subscriptions {
6195            if included.insert(id.clone()) {
6196                ordered_subscriptions.push(subscription.clone());
6197            }
6198        }
6199        let subscriptions_truncated =
6200            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6201        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6202        let captured_at_ms = self.clock_now_ms();
6203        let lease = if let Some(error_code) = self
6204            .lease_state
6205            .as_ref()
6206            .and_then(|state| state.error_code.clone())
6207        {
6208            ClientDiagnosticsLease {
6209                state: "stopped".to_owned(),
6210                expires_at_ms: self
6211                    .lease_state
6212                    .as_ref()
6213                    .and_then(|state| state.expires_at_ms),
6214                error_code: Some(Self::diagnostic_code(&error_code)),
6215            }
6216        } else if let Some(expires_at_ms) = self
6217            .lease_state
6218            .as_ref()
6219            .and_then(|state| state.expires_at_ms)
6220        {
6221            ClientDiagnosticsLease {
6222                state: if expires_at_ms <= captured_at_ms {
6223                    "expired".to_owned()
6224                } else {
6225                    "active".to_owned()
6226                },
6227                expires_at_ms: Some(expires_at_ms),
6228                error_code: None,
6229            }
6230        } else {
6231            ClientDiagnosticsLease {
6232                state: "none".to_owned(),
6233                expires_at_ms: None,
6234                error_code: None,
6235            }
6236        };
6237        let connectivity = match self.last_round.as_ref() {
6238            Some(round) if round.status == "succeeded" => "online",
6239            Some(round)
6240                if round.status == "failed"
6241                    && round
6242                        .error_code
6243                        .as_deref()
6244                        .is_some_and(Self::retryable_transport_code) =>
6245            {
6246                "offline"
6247            }
6248            _ => "unknown",
6249        };
6250        Ok(ClientDiagnosticsSnapshot {
6251            version: CLIENT_DIAGNOSTICS_VERSION,
6252            captured_at_ms,
6253            host: ClientDiagnosticsHost {
6254                kind: "direct".to_owned(),
6255                role: "single".to_owned(),
6256                connectivity: connectivity.to_owned(),
6257                realtime: self.realtime_state,
6258                realtime_policy: self.realtime_policy,
6259                realtime_reason_code: self.realtime_reason_code.clone(),
6260                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6261            },
6262            security_lifecycle: self.security_lifecycle().to_owned(),
6263            schema: ClientDiagnosticsSchema {
6264                current_version: self.schema.version,
6265                upgrading: self.upgrading,
6266                required_version: self
6267                    .schema_floor
6268                    .as_ref()
6269                    .and_then(|floor| floor.required_schema_version),
6270                latest_version: self
6271                    .schema_floor
6272                    .as_ref()
6273                    .and_then(|floor| floor.latest_schema_version),
6274            },
6275            replica: ClientDiagnosticsReplica {
6276                local_revision: self.local_revision().to_string(),
6277                sync_needed: self.sync_needed,
6278                pending_outbox: self.outbox.len(),
6279            },
6280            lease,
6281            subscriptions: ordered_subscriptions,
6282            subscriptions_truncated,
6283            last_round: self.last_round.clone(),
6284            last_change: self.last_change.clone(),
6285            storage: self.diagnostics_storage(),
6286            query_failures: self.query_failures.clone(),
6287        })
6288    }
6289
6290    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6291        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6292            let page_count: i64 = self
6293                .conn
6294                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6295            let page_size: i64 = self
6296                .conn
6297                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6298            // Reuse compiled aggregates; every invocation still reads current storage.
6299            let outbox_bytes: i64 = self
6300                .conn
6301                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6302                .query_row([], |row| row.get(0))?;
6303            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6304                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6305            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6306            let blob_bytes = if self.schema_has_blobs() {
6307                self.conn
6308                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6309                    .query_row([], |row| row.get(0))?
6310            } else {
6311                0
6312            };
6313            let pressure = self
6314                .limits
6315                .blob_cache_max_bytes
6316                .is_some_and(|limit| blob_bytes > limit);
6317            Ok(ClientDiagnosticsStorage {
6318                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6319                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6320                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6321                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6322                retained_outcome_entries: Some(outcome_entries.max(0)),
6323                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6324                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6325            })
6326        };
6327        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6328            status: "unreadable".to_owned(),
6329            database_bytes_approx: None,
6330            pending_outbox_bytes_approx: None,
6331            retained_outcome_bytes_approx: None,
6332            retained_outcome_entries: None,
6333            blob_cache_bytes_approx: None,
6334            pressure_reason_code: None,
6335        })
6336    }
6337
6338    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6339        self.change_queue.drain(..).collect()
6340    }
6341
6342    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6343        self.sync_intent_queue.drain(..).collect()
6344    }
6345
6346    fn schedule_background_retry(&mut self) -> u64 {
6347        let delay_ms = self.retry_delay_ms;
6348        self.sync_intent_queue
6349            .push_back(SyncIntent::Background { delay_ms });
6350        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6351        delay_ms
6352    }
6353
6354    fn reset_background_retry(&mut self) {
6355        self.retry_delay_ms = 250;
6356    }
6357
6358    fn retryable_transport_code(code: &str) -> bool {
6359        code == "transport.failed"
6360            || code == "transport.unavailable"
6361            || code == "sync.transport_failed"
6362    }
6363
6364    /// A failed round worth a background retry: a transport failure, or a
6365    /// §10.2 code the catalog marks retryable (clients may hardcode the
6366    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
6367    /// on the server's `retryable` flag.
6368    fn retryable_failure_code(code: &str) -> bool {
6369        Self::retryable_transport_code(code)
6370            || matches!(
6371                code,
6372                "sync.auth_required"
6373                    | "sync.auth_lease_required"
6374                    | "sync.auth_lease_revoked"
6375                    | "sync.internal_error"
6376                    | "sync.idempotency_cache_miss"
6377                    | "sync.schema_not_ready"
6378                    | "sync.segment_expired"
6379                    | "sync.rate_limited"
6380                    | "sync.websocket_connection_limit"
6381            )
6382    }
6383
6384    fn diagnostic_code(code: &str) -> String {
6385        let valid = !code.is_empty()
6386            && code.len() <= 96
6387            && code.contains('.')
6388            && code
6389                .bytes()
6390                .next()
6391                .is_some_and(|byte| byte.is_ascii_lowercase())
6392            && code.bytes().all(|byte| {
6393                byte.is_ascii_lowercase()
6394                    || byte.is_ascii_digit()
6395                    || matches!(byte, b'.' | b'_' | b'-')
6396            });
6397        if valid {
6398            code.to_owned()
6399        } else {
6400            "client.unknown_failure".to_owned()
6401        }
6402    }
6403
6404    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6405        if self.sync_needed != value {
6406            if self.begin_observation("syncular_status").is_ok() {
6407                self.sync_needed = value;
6408                let batch = ChangeAccumulator {
6409                    status: true,
6410                    ..ChangeAccumulator::default()
6411                };
6412                if self.finish_observation("syncular_status", batch).is_err() {
6413                    self.rollback_observation("syncular_status");
6414                }
6415            } else {
6416                self.sync_needed = value;
6417            }
6418        }
6419        if value && interactive {
6420            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6421        }
6422    }
6423
6424    fn begin_observation(&self, name: &str) -> Result<(), String> {
6425        self.conn
6426            .execute_batch(&format!("SAVEPOINT {name}"))
6427            .map_err(|error| error.to_string())
6428    }
6429
6430    fn rollback_observation(&self, name: &str) {
6431        let _ = self
6432            .conn
6433            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
6434    }
6435
6436    fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
6437        #[cfg(feature = "bench-internals")]
6438        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6439        if !batch.touched() {
6440            self.conn
6441                .execute_batch(&format!("RELEASE {name}"))
6442                .map_err(|error| error.to_string())?;
6443            return Ok(());
6444        }
6445        let revision = self
6446            .local_revision()
6447            .checked_add(1)
6448            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6449        self.conn
6450            .execute(
6451                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6452                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6453            )
6454            .map_err(|error| error.to_string())?;
6455        let status = batch.status.then(|| self.status_snapshot());
6456        let event = ClientChangeBatch {
6457            revision: revision.to_string(),
6458            tables: batch
6459                .tables
6460                .into_iter()
6461                .map(|(table, scope_keys)| TableChange {
6462                    table,
6463                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6464                })
6465                .collect(),
6466            windows: batch
6467                .windows
6468                .into_iter()
6469                .map(|((base_key, table), units)| WindowChange {
6470                    base_key,
6471                    table,
6472                    units: units.into_iter().collect(),
6473                })
6474                .collect(),
6475            status,
6476            conflicts_changed: batch.conflicts,
6477            rejections_changed: batch.rejections,
6478            outcomes_changed: batch.outcomes,
6479        };
6480        self.conn
6481            .execute_batch(&format!("RELEASE {name}"))
6482            .map_err(|error| error.to_string())?;
6483        let mut diagnostic_tables = event
6484            .tables
6485            .iter()
6486            .map(|entry| entry.table.clone())
6487            .collect::<BTreeSet<_>>()
6488            .into_iter()
6489            .collect::<Vec<_>>();
6490        let mut diagnostic_windows = event
6491            .windows
6492            .iter()
6493            .map(|entry| entry.table.clone())
6494            .collect::<BTreeSet<_>>()
6495            .into_iter()
6496            .collect::<Vec<_>>();
6497        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6498            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6499        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6500        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6501        self.last_change = Some(DiagnosticLastChange {
6502            revision: event.revision.clone(),
6503            recorded_at_ms: self.clock_now_ms(),
6504            tables: diagnostic_tables,
6505            windows: diagnostic_windows,
6506            domains_truncated,
6507            status_changed: event.status.is_some(),
6508            conflicts_changed: event.conflicts_changed,
6509            rejections_changed: event.rejections_changed,
6510            outcomes_changed: event.outcomes_changed,
6511        });
6512        self.change_queue.push_back(event);
6513        Ok(())
6514    }
6515
6516    fn record_scope_map(
6517        &self,
6518        batch: &mut ChangeAccumulator,
6519        table_name: &str,
6520        scopes: &[(String, Vec<String>)],
6521    ) {
6522        let Some(table) = self.schema.table(table_name) else {
6523            return;
6524        };
6525        for (variable, values) in scopes {
6526            let Some(scope) = table
6527                .scope_variables
6528                .iter()
6529                .find(|scope| &scope.variable == variable)
6530            else {
6531                continue;
6532            };
6533            for value in values {
6534                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6535            }
6536        }
6537    }
6538
6539    /// Record a row's current scope keys from the base or visible table.
6540    fn record_row_scopes(
6541        &self,
6542        batch: &mut ChangeAccumulator,
6543        table_name: &str,
6544        row_id: &str,
6545        base: bool,
6546    ) -> bool {
6547        let Some(table) = self.schema.table(table_name) else {
6548            return false;
6549        };
6550        if table.scope_variables.is_empty() {
6551            return false;
6552        }
6553        let columns = table
6554            .scope_variables
6555            .iter()
6556            .map(|scope| quote_ident(&scope.column))
6557            .collect::<Vec<_>>()
6558            .join(", ");
6559        let full_table = if base {
6560            base_table(table_name)
6561        } else {
6562            visible_table(table_name)
6563        };
6564        let sql = format!(
6565            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6566            row_id_predicate(table)
6567        );
6568        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6569            return false;
6570        };
6571        let values = stmt.query_row(rusqlite::params![row_id], |row| {
6572            let mut values = Vec::with_capacity(table.scope_variables.len());
6573            for index in 0..table.scope_variables.len() {
6574                values.push(row.get::<_, Option<String>>(index)?);
6575            }
6576            Ok(values)
6577        });
6578        let Ok(values) = values else {
6579            return false;
6580        };
6581        let mut recorded = false;
6582        for (scope, value) in table.scope_variables.iter().zip(values) {
6583            if let Some(value) = value {
6584                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6585                recorded = true;
6586            }
6587        }
6588        recorded
6589    }
6590
6591    fn record_commit_changes(
6592        &self,
6593        batch: &mut ChangeAccumulator,
6594        tables: &[String],
6595        changes: &[ssp2::model::Change],
6596    ) {
6597        #[cfg(feature = "bench-internals")]
6598        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6599        for change in changes {
6600            let Some(table_name) = tables.get(change.table_index as usize) else {
6601                continue;
6602            };
6603            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6604            if let Some(table) = self.schema.table(table_name) {
6605                for (variable, value) in &change.scopes {
6606                    if let Some(scope) = table
6607                        .scope_variables
6608                        .iter()
6609                        .find(|scope| &scope.variable == variable)
6610                    {
6611                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
6612                        precise = true;
6613                    }
6614                }
6615            }
6616            if !precise {
6617                batch.table(table_name);
6618            }
6619        }
6620    }
6621
6622    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6623        if effective.is_empty() {
6624            return false;
6625        }
6626        let Some(table) = self.schema.table(table_name) else {
6627            return false;
6628        };
6629        let mut clauses = Vec::new();
6630        let mut params = Vec::new();
6631        for (variable, values) in effective {
6632            let Some(column) = table.scope_column(variable) else {
6633                return false;
6634            };
6635            if values.is_empty() {
6636                return false;
6637            }
6638            let placeholders = values
6639                .iter()
6640                .map(|value| {
6641                    params.push(SqlValue::Text(value.clone()));
6642                    "?"
6643                })
6644                .collect::<Vec<_>>()
6645                .join(", ");
6646            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6647        }
6648        let sql = format!(
6649            "SELECT 1 FROM {} WHERE {} LIMIT 1",
6650            base_table(table_name),
6651            clauses.join(" AND ")
6652        );
6653        self.conn
6654            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6655            .is_ok()
6656    }
6657
6658    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
6659    pub fn upgrading(&self) -> bool {
6660        self.upgrading
6661    }
6662
6663    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
6664    /// keeping this client's local database (identity, outbox, tables). The
6665    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
6666    /// version changed. Mirrors the TS client's boot-time detection —
6667    /// the Rust core has no persistent restart, so recreation IS the boot.
6668    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6669        let new_schema = parse_schema_json(schema_json)?;
6670        let marker: Option<i32> = self
6671            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6672            .and_then(|v| v.parse().ok());
6673        self.schema = new_schema;
6674        self.create_bookkeeping_tables()?;
6675        if marker != Some(self.schema.version) {
6676            self.run_schema_reset()?;
6677        }
6678        self.prune_unknown_subscriptions()?;
6679        // The conformance recreate is the in-memory equivalent of reopening a
6680        // durable client. Apply the same startup catch-up contract even when
6681        // the schema itself did not change.
6682        self.enqueue_startup_sync_if_needed()?;
6683        Ok(())
6684    }
6685
6686    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6687        let startup_work = !self.stopped
6688            && (!self.outbox.is_empty()
6689                || !self.load_pending_evictions()?.is_empty()
6690                || self.subs.iter().any(|sub| sub.state == SubState::Active));
6691        if startup_work {
6692            self.sync_needed = true;
6693            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6694        }
6695        Ok(())
6696    }
6697
6698    /// A native client persists schema-floor stops across process restarts.
6699    /// Once the running app already satisfies that floor, the persisted stop
6700    /// is only stale evidence from an older server round (for example, the
6701    /// server was restarted after catching up to an app that was ahead).
6702    /// Clear it and let the normal startup pull re-negotiate. If the server is
6703    /// still incompatible it will return the floor again in that first round.
6704    fn clear_satisfied_persisted_schema_floor(&mut self) {
6705        let satisfied = self
6706            .schema_floor
6707            .as_ref()
6708            .and_then(|floor| floor.required_schema_version)
6709            .is_some_and(|required| self.schema.version >= required);
6710        if !satisfied {
6711            return;
6712        }
6713        self.schema_floor = None;
6714        self.stopped = false;
6715        self.delete_meta(SCHEMA_FLOOR_KEY);
6716    }
6717
6718    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
6719    /// path already swept it; this covers a same-version or fresh-install open,
6720    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
6721    /// an aware binary can apply.
6722    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6723        let Some(replica_path) = self.previous_version_replica_path() else {
6724            return Ok(());
6725        };
6726        let max_age_ms = self
6727            .previous_version
6728            .filter(|config| config.enabled)
6729            .map(|config| config.max_age_ms);
6730        reconcile_previous_version_at_boot(
6731            &self.conn,
6732            &replica_path,
6733            self.schema.version,
6734            max_age_ms,
6735            self.clock_now_ms(),
6736        )
6737    }
6738
6739    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
6740    /// no file — an in-memory replica returns an empty path, and a `file:` URI
6741    /// has no plain sibling path — so the feature resolves as off there.
6742    fn previous_version_replica_path(&self) -> Option<String> {
6743        let path = self.conn.path()?;
6744        if path.is_empty() || path.starts_with("file:") {
6745            return None;
6746        }
6747        Some(path.to_owned())
6748    }
6749
6750    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
6751    /// and leaseState. Drops/recreates every synced table from the new
6752    /// schema, resets subscription sync-state (keeping registrations), clears
6753    /// the schema-floor stop state, rewrites the marker, drops outbox commits
6754    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
6755    fn run_schema_reset(&mut self) -> Result<(), String> {
6756        self.begin_observation("syncular_schema_reset")?;
6757        let mut batch = ChangeAccumulator::default();
6758        let result = self.run_schema_reset_observed(&mut batch, true, true);
6759        if let Err(error) = result {
6760            self.rollback_observation("syncular_schema_reset");
6761            return Err(error);
6762        }
6763        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6764            self.rollback_observation("syncular_schema_reset");
6765            return Err(error);
6766        }
6767        Ok(())
6768    }
6769
6770    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6771        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6772        self.begin_observation("syncular_log_epoch_reset")?;
6773        let mut batch = ChangeAccumulator::default();
6774        let result = self.run_schema_reset_observed(&mut batch, false, false);
6775        if let Err(error) = result {
6776            self.rollback_observation("syncular_log_epoch_reset");
6777            return Err(error);
6778        }
6779        self.set_meta(LOG_EPOCH_KEY, log_epoch);
6780        self.sync_needed = true;
6781        batch.status = true;
6782        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6783            self.rollback_observation("syncular_log_epoch_reset");
6784            return Err(error);
6785        }
6786        self.sync_intent_queue.push_back(SyncIntent::Interactive);
6787        Ok(resets)
6788    }
6789
6790    fn run_schema_reset_observed(
6791        &mut self,
6792        batch: &mut ChangeAccumulator,
6793        drop_incompatible: bool,
6794        capture: bool,
6795    ) -> Result<(), String> {
6796        self.upgrading = true;
6797        batch.status = true;
6798        // RFC 0005 D6: classify the pending outbox against the NEW compiled
6799        // schema BEFORE the wipe, and record the advisory audit. It drops
6800        // nothing — the §7.4.4 send-time drop stays the only path that removes
6801        // a commit. The classification is shared with that drop so the two can
6802        // never disagree about what is incompatible.
6803        if self.previous_version.is_some_and(|config| config.enabled) {
6804            let previous_version = self
6805                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6806                .and_then(|value| value.parse::<i32>().ok())
6807                .unwrap_or(self.schema.version);
6808            let pending = self
6809                .outbox
6810                .iter()
6811                .map(|commit| PendingCommitAudit {
6812                    commit_id: &commit.client_commit_id,
6813                    operations: commit_audit_operations(commit),
6814                })
6815                .collect::<Vec<_>>();
6816            crate::previous_version::write_previous_version_audit(
6817                &self.conn,
6818                &crate::previous_version::build_previous_version_audit(
6819                    &self.schema,
6820                    &pending,
6821                    previous_version,
6822                    self.schema.version,
6823                    self.clock_now_ms(),
6824                ),
6825            );
6826        }
6827        // RFC 0005 D5: the container lives in its own FILE, so the reset can
6828        // never reach it. Sweep first (unconditional — also on the log-epoch
6829        // reset, which captures nothing), then capture BEFORE the wipe drops
6830        // any row. A refused capture records its reason and leaves no file.
6831        if let Some(replica_path) = self.previous_version_replica_path() {
6832            sweep_previous_version_container(&self.conn, &replica_path)?;
6833            if capture {
6834                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
6835                    let previous_version = self
6836                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6837                        .and_then(|value| value.parse::<i32>().ok())
6838                        .unwrap_or(self.schema.version);
6839                    capture_previous_version_from_replica(
6840                        &self.conn,
6841                        &replica_path,
6842                        previous_version,
6843                        self.schema.version,
6844                        &config,
6845                        self.clock_now_ms(),
6846                    )?;
6847                }
6848            }
6849        }
6850        for table in &self.schema.tables {
6851            batch.table(&table.name);
6852        }
6853        for (base_key, unit, table) in self.load_registered_window_units() {
6854            batch.window(&base_key, &table, &unit);
6855        }
6856        // The per-table insert SQL is derived from the OLD column lists.
6857        self.insert_sql.borrow_mut().clear();
6858        self.overlay_dirty.set(true);
6859        // Drop every synced table (base + visible) that currently exists —
6860        // discovered from sqlite_master so a bump that adds/removes tables is
6861        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
6862        // and the blob cache) are preserved; base tables are `_syncular_base_*`
6863        // so they are matched explicitly, not by the bookkeeping filter.
6864        // Drop virtual tables first so SQLite removes every FTS shadow table
6865        // atomically instead of the generic discovery tearing it apart.
6866        let virtual_tables: Vec<String> = {
6867            let mut stmt = self
6868                .conn
6869                .prepare(
6870                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
6871                )
6872                .map_err(|e| e.to_string())?;
6873            let rows = stmt
6874                .query_map([], |row| row.get::<_, String>(0))
6875                .map_err(|e| e.to_string())?;
6876            rows.filter_map(Result::ok)
6877                .filter(|name| is_synced_table_name(name))
6878                .collect()
6879        };
6880        for name in virtual_tables {
6881            self.conn
6882                .execute_batch(&format!(
6883                    "DROP TABLE IF EXISTS {}",
6884                    quote_ident(&format!("_syncular_fts_{name}"))
6885                ))
6886                .map_err(|e| e.to_string())?;
6887            self.conn
6888                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6889                .map_err(|e| e.to_string())?;
6890        }
6891        let existing: Vec<String> = {
6892            let mut stmt = self
6893                .conn
6894                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
6895                .map_err(|e| e.to_string())?;
6896            let rows = stmt
6897                .query_map([], |row| row.get::<_, String>(0))
6898                .map_err(|e| e.to_string())?;
6899            rows.filter_map(Result::ok)
6900                .filter(|name| is_synced_table_name(name))
6901                .collect()
6902        };
6903        for name in &existing {
6904            if let Some(table) = name.strip_prefix("_syncular_base_") {
6905                batch.table(table);
6906            } else if !name.starts_with("_syncular_") {
6907                batch.table(name);
6908            }
6909        }
6910        for name in existing {
6911            self.conn
6912                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6913                .map_err(|e| e.to_string())?;
6914        }
6915        // Recreate the synced tables from the NEW schema.
6916        self.create_synced_tables()?;
6917        // Reset every subscription's sync-state, keeping the registration.
6918        for sub in &mut self.subs {
6919            sub.cursor = -1;
6920            sub.bootstrap_state = None;
6921            sub.effective = None;
6922            sub.state = SubState::Active;
6923            sub.reason_code = None;
6924            sub.synced_once = false;
6925        }
6926        let subs = self.subs.clone();
6927        for sub in &subs {
6928            self.persist_sub(sub)?;
6929        }
6930        // The stop state is over: this client now ships a servable schema.
6931        self.stopped = false;
6932        self.schema_floor = None;
6933        self.delete_meta(SCHEMA_FLOOR_KEY);
6934        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
6935        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
6936        // D1: the descriptor follows every marker write. A crash in between
6937        // self-heals on the next same-version open, which backfills it.
6938        set_local_schema_descriptor(&self.conn, &self.schema);
6939        // §7.4.4: drop outbox commits that cannot re-encode under the new
6940        // schema (a referenced column/table the bump removed), surfacing each
6941        // as a `sync.outbox_incompatible` rejection.
6942        if drop_incompatible && self.drop_incompatible_outbox()? {
6943            batch.rejections = true;
6944            batch.status = true;
6945            batch.outcomes = true;
6946        }
6947        // Re-apply the surviving outbox optimistically over the empty tables.
6948        self.rebuild_overlay();
6949        Ok(())
6950    }
6951
6952    /// §7.4.4: a persisted upsert whose values reference a column the current
6953    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
6954    /// and raise a client-local `sync.outbox_incompatible` rejection.
6955    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
6956        let incompatible = self
6957            .outbox
6958            .iter()
6959            .filter(|commit| {
6960                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
6961            })
6962            .cloned()
6963            .collect::<Vec<_>>();
6964        if incompatible.is_empty() {
6965            return Ok(false);
6966        }
6967        let mut rejections = Vec::new();
6968        for commit in &incompatible {
6969            let results = commit
6970                .ops
6971                .iter()
6972                .enumerate()
6973                .map(|(op_index, operation)| {
6974                    let rejection = RejectionRecord {
6975                        client_commit_id: commit.client_commit_id.clone(),
6976                        op_index: op_index as i32,
6977                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
6978                        message: "the persisted commit cannot encode under the current schema"
6979                            .to_owned(),
6980                        retryable: false,
6981                        details: None,
6982                        operation: Some(CommitOperation::from(operation)),
6983                    };
6984                    rejections.push(rejection.clone());
6985                    CommitOperationOutcome::Error { rejection }
6986                })
6987                .collect::<Vec<_>>();
6988            self.persist_commit_outcome(
6989                &commit.client_commit_id,
6990                CommitOutcomeStatus::Rejected,
6991                &results,
6992                Some(&commit.ops),
6993            )?;
6994            self.delete_outbox_persisted(&commit.client_commit_id)?;
6995        }
6996        self.prune_commit_outcomes()?;
6997        let incompatible_ids = incompatible
6998            .iter()
6999            .map(|commit| commit.client_commit_id.as_str())
7000            .collect::<BTreeSet<_>>();
7001        self.outbox
7002            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7003        self.rejections.extend(rejections);
7004        Ok(true)
7005    }
7006
7007    /// §7.4.3: (re)create the base + visible table pair for every synced
7008    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
7009    fn create_synced_tables(&self) -> Result<(), String> {
7010        for table in &self.schema.tables {
7011            // The base half + the visible half form the synced-table pair. An
7012            // index name is global in SQLite, so the base half's indexes are
7013            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
7014            for (full, index_prefix) in [
7015                (base_table(&table.name), "_syncular_base_"),
7016                (visible_table(&table.name), ""),
7017            ] {
7018                let mut cols: Vec<String> =
7019                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7020                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7021                let sql = format!(
7022                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7023                    cols.join(", "),
7024                    quote_ident(&table.primary_key)
7025                );
7026                self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
7027                // Local secondary indexes (CREATE INDEX subset). Created on
7028                // both halves so mirror reads hit an index on either. Runs on
7029                // both the initial create and the §7.4.3 reset recreate path.
7030                for index in &table.indexes {
7031                    let unique = if index.unique { "UNIQUE " } else { "" };
7032                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7033                    let cols_sql = index
7034                        .columns
7035                        .iter()
7036                        .map(|c| quote_ident(c))
7037                        .collect::<Vec<_>>()
7038                        .join(", ");
7039                    let index_sql = format!(
7040                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7041                    );
7042                    self.conn
7043                        .execute(&index_sql, [])
7044                        .map_err(|e| e.to_string())?;
7045                }
7046            }
7047        }
7048        // FTS exists only on the visible half. It is a local search
7049        // projection, never a synced/base table or a wire-schema member.
7050        for table in &self.schema.tables {
7051            for index in &table.fts_indexes {
7052                self.create_fts_projection(table, index)?;
7053            }
7054        }
7055        Ok(())
7056    }
7057
7058    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7059        let count: i64 = self
7060            .conn
7061            .query_row(
7062                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7063                rusqlite::params![index.name],
7064                |row| row.get(0),
7065            )
7066            .map_err(|e| e.to_string())?;
7067        Ok(count > 0)
7068    }
7069
7070    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7071        for suffix in ["ai", "ad", "au"] {
7072            self.conn
7073                .execute(
7074                    &format!(
7075                        "DROP TRIGGER IF EXISTS {}",
7076                        quote_ident(&format!("{}_{suffix}", index.name))
7077                    ),
7078                    [],
7079                )
7080                .map_err(|e| e.to_string())?;
7081        }
7082        Ok(())
7083    }
7084
7085    fn create_fts_triggers(
7086        &self,
7087        table: &TableSchema,
7088        index: &FtsIndexSchema,
7089    ) -> Result<(), String> {
7090        let fts = quote_ident(&index.name);
7091        let source = visible_table(&table.name);
7092        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7093        let pk = quote_ident(&table.primary_key);
7094        let projection_columns = std::iter::once(source_id.clone())
7095            .chain(index.columns.iter().map(|column| quote_ident(column)))
7096            .collect::<Vec<_>>()
7097            .join(", ");
7098        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7099            .chain(
7100                index
7101                    .columns
7102                    .iter()
7103                    .map(|column| format!("new.{}", quote_ident(column))),
7104            )
7105            .collect::<Vec<_>>()
7106            .join(", ");
7107        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7108        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)");
7109        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)");
7110        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})");
7111        let bi = quote_ident(&format!("{}_bi", index.name));
7112        let ai = quote_ident(&format!("{}_ai", index.name));
7113        let ad = quote_ident(&format!("{}_ad", index.name));
7114        let au = quote_ident(&format!("{}_au", index.name));
7115        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7116        let sql = format!(
7117            "DROP TRIGGER IF EXISTS {bi};
7118             DROP TRIGGER IF EXISTS {ai};
7119             DROP TRIGGER IF EXISTS {ad};
7120             DROP TRIGGER IF EXISTS {au};
7121             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7122             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7123             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7124             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7125        );
7126        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
7127    }
7128
7129    fn rebuild_fts_projection(
7130        &self,
7131        table: &TableSchema,
7132        index: &FtsIndexSchema,
7133    ) -> Result<(), String> {
7134        let fts = quote_ident(&index.name);
7135        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7136        let pk = quote_ident(&table.primary_key);
7137        let indexed_columns = index
7138            .columns
7139            .iter()
7140            .map(|column| quote_ident(column))
7141            .collect::<Vec<_>>();
7142        let projection_columns = std::iter::once(source_id.clone())
7143            .chain(indexed_columns.iter().cloned())
7144            .collect::<Vec<_>>()
7145            .join(", ");
7146        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7147        let sql = format!(
7148            "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};",
7149            columns = indexed_columns.join(", "),
7150            source = visible_table(&table.name),
7151        );
7152        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
7153    }
7154
7155    fn create_fts_projection(
7156        &self,
7157        table: &TableSchema,
7158        index: &FtsIndexSchema,
7159    ) -> Result<(), String> {
7160        self.begin_observation("syncular_fts_schema")?;
7161        let result = (|| {
7162            let existed = self.fts_projection_exists(index)?;
7163            let tokenizer = index.tokenize.replace('\'', "''");
7164            let columns = index
7165                .columns
7166                .iter()
7167                .map(|column| quote_ident(column))
7168                .collect::<Vec<_>>()
7169                .join(", ");
7170            let sql = format!(
7171            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7172            fts = quote_ident(&index.name),
7173            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7174        );
7175            self.conn.execute(&sql, []).map_err(|error| {
7176                format!(
7177                    "cannot create local FTS5 projection {:?}: {error}",
7178                    index.name
7179                )
7180            })?;
7181            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7182            let mapped: bool = self
7183                .conn
7184                .query_row(
7185                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7186                    [format!("_syncular_fts_{}", index.name)],
7187                    |row| row.get(0),
7188                )
7189                .map_err(|e| e.to_string())?;
7190            self.conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS {mapping}(id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)")).map_err(|e| e.to_string())?;
7191            if !mapped && existed {
7192                self.conn
7193                    .execute_batch(&format!(
7194                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7195                        quote_ident(FTS_SOURCE_ID_COLUMN),
7196                        quote_ident(&index.name)
7197                    ))
7198                    .map_err(|e| e.to_string())?;
7199            }
7200            self.create_fts_triggers(table, index)?;
7201            if !existed {
7202                self.rebuild_fts_projection(table, index)?;
7203            }
7204            Ok(())
7205        })();
7206        if let Err(error) = result {
7207            self.rollback_observation("syncular_fts_schema");
7208            return Err(error);
7209        }
7210        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7211            self.rollback_observation("syncular_fts_schema");
7212            return Err(error.to_string());
7213        }
7214        Ok(())
7215    }
7216
7217    // -- persistence write-through --------------------------------------------
7218
7219    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7220        let state = serde_json::json!({
7221            "requested": scope_map_to_json(&sub.requested),
7222            "params": sub.params,
7223            "cursor": sub.cursor,
7224            "bootstrapState": sub.bootstrap_state,
7225            "status": sub.state.name(),
7226            "reasonCode": sub.reason_code,
7227            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7228            "syncedOnce": sub.synced_once,
7229        });
7230        self.conn.execute(
7231            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7232            rusqlite::params![sub.id, sub.table, state.to_string()],
7233        ).map(|_| ()).map_err(|error| error.to_string())
7234    }
7235
7236    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7237        let ops: Vec<Value> = commit
7238            .ops
7239            .iter()
7240            .map(|op| {
7241                serde_json::json!({
7242                    "op": if op.upsert { "upsert" } else { "delete" },
7243                    "table": op.table,
7244                    "rowId": op.row_id,
7245                    "baseVersion": op.base_version,
7246                    "values": op.values.clone().map(Value::Object),
7247                })
7248            })
7249            .collect();
7250        self.conn
7251            .execute(
7252                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7253                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7254            )
7255            .map_err(|error| error.to_string())?;
7256        for operation in &commit.ops {
7257            let Some(values) = &operation.values else {
7258                continue;
7259            };
7260            let Some(table) = self.schema.table(&operation.table) else {
7261                continue;
7262            };
7263            for column in table
7264                .columns
7265                .iter()
7266                .filter(|column| column.ty == ColumnType::BlobRef)
7267            {
7268                let Some(Value::String(raw)) = values.get(&column.name) else {
7269                    continue;
7270                };
7271                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7272                    continue;
7273                };
7274                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7275                    continue;
7276                };
7277                self.conn
7278                    .execute(
7279                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7280                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7281                        rusqlite::params![commit.client_commit_id, blob_id],
7282                    )
7283                    .map_err(|error| error.to_string())?;
7284            }
7285        }
7286        Ok(())
7287    }
7288
7289    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7290        self.conn
7291            .execute(
7292                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7293                rusqlite::params![client_commit_id],
7294            )
7295            .map_err(|error| error.to_string())?;
7296        self.conn
7297            .execute(
7298                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7299                rusqlite::params![client_commit_id],
7300            )
7301            .map(|_| ())
7302            .map_err(|error| error.to_string())
7303    }
7304
7305    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7306        match status {
7307            CommitOutcomeStatus::Applied => "applied",
7308            CommitOutcomeStatus::Cached => "cached",
7309            CommitOutcomeStatus::Conflict => "conflict",
7310            CommitOutcomeStatus::Rejected => "rejected",
7311        }
7312    }
7313
7314    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7315        match resolution {
7316            CommitOutcomeResolution::Active => "active",
7317            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7318            CommitOutcomeResolution::Superseded => "superseded",
7319            CommitOutcomeResolution::Dismissed => "dismissed",
7320        }
7321    }
7322
7323    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7324        match value {
7325            "applied" => Ok(CommitOutcomeStatus::Applied),
7326            "cached" => Ok(CommitOutcomeStatus::Cached),
7327            "conflict" => Ok(CommitOutcomeStatus::Conflict),
7328            "rejected" => Ok(CommitOutcomeStatus::Rejected),
7329            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7330        }
7331    }
7332
7333    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7334        match value {
7335            "active" => Ok(CommitOutcomeResolution::Active),
7336            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7337            "superseded" => Ok(CommitOutcomeResolution::Superseded),
7338            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7339            _ => Err(format!(
7340                "invalid persisted commit outcome resolution {value:?}"
7341            )),
7342        }
7343    }
7344
7345    fn persist_commit_outcome(
7346        &self,
7347        client_commit_id: &str,
7348        status: CommitOutcomeStatus,
7349        results: &[CommitOperationOutcome],
7350        operations: Option<&[OutboxOp]>,
7351    ) -> Result<(), String> {
7352        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7353        let operations_json = operations
7354            .map(|items| {
7355                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7356            })
7357            .transpose()
7358            .map_err(|error| error.to_string())?;
7359        self.conn
7360            .execute(
7361                "INSERT INTO _syncular_commit_outcomes (
7362                   client_commit_id, status, recorded_at_ms, results_json,
7363                   operations_json, resolution
7364                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7365                rusqlite::params![
7366                    client_commit_id,
7367                    Self::outcome_status_name(status),
7368                    self.clock_now_ms(),
7369                    results_json,
7370                    operations_json
7371                ],
7372            )
7373            .map(|_| ())
7374            .map_err(|error| error.to_string())
7375    }
7376
7377    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7378        let StoredCommitOutcomeRow {
7379            sequence,
7380            client_commit_id,
7381            status,
7382            recorded_at_ms,
7383            results_json,
7384            operations_json,
7385            resolution,
7386            resolved_at_ms,
7387            replacement_client_commit_id,
7388        } = row;
7389        Ok(CommitOutcome {
7390            sequence,
7391            client_commit_id,
7392            status: Self::parse_outcome_status(&status)?,
7393            recorded_at_ms,
7394            results: serde_json::from_str(&results_json)
7395                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7396            operations: operations_json
7397                .map(|value| {
7398                    serde_json::from_str(&value).map_err(|error| {
7399                        format!("invalid persisted commit outcome operations: {error}")
7400                    })
7401                })
7402                .transpose()?,
7403            resolution: Self::parse_outcome_resolution(&resolution)?,
7404            resolved_at_ms,
7405            replacement_client_commit_id,
7406        })
7407    }
7408
7409    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7410        let row = self
7411            .conn
7412            .query_row(
7413                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7414                        resolution, resolved_at_ms, replacement_client_commit_id
7415                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7416                rusqlite::params![client_commit_id],
7417                |row| {
7418                    Ok(StoredCommitOutcomeRow {
7419                        sequence: row.get(0)?,
7420                        client_commit_id: row.get(1)?,
7421                        status: row.get(2)?,
7422                        recorded_at_ms: row.get(3)?,
7423                        results_json: row.get(4)?,
7424                        operations_json: row.get(5)?,
7425                        resolution: row.get(6)?,
7426                        resolved_at_ms: row.get(7)?,
7427                        replacement_client_commit_id: row.get(8)?,
7428                    })
7429                },
7430            )
7431            .optional()
7432            .map_err(|error| error.to_string())?;
7433        row.map(Self::outcome_from_row).transpose()
7434    }
7435
7436    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7437        if query.limit == Some(0) {
7438            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7439        }
7440        let mut sql = String::from(
7441            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7442                    resolution, resolved_at_ms, replacement_client_commit_id
7443               FROM _syncular_commit_outcomes",
7444        );
7445        if query.active_only {
7446            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7447        }
7448        sql.push_str(" ORDER BY seq DESC");
7449        if let Some(limit) = query.limit {
7450            sql.push_str(&format!(" LIMIT {limit}"));
7451        }
7452        let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
7453        let rows = stmt
7454            .query_map([], |row| {
7455                Ok(StoredCommitOutcomeRow {
7456                    sequence: row.get(0)?,
7457                    client_commit_id: row.get(1)?,
7458                    status: row.get(2)?,
7459                    recorded_at_ms: row.get(3)?,
7460                    results_json: row.get(4)?,
7461                    operations_json: row.get(5)?,
7462                    resolution: row.get(6)?,
7463                    resolved_at_ms: row.get(7)?,
7464                    replacement_client_commit_id: row.get(8)?,
7465                })
7466            })
7467            .map_err(|error| error.to_string())?;
7468        let mut outcomes = Vec::new();
7469        for row in rows {
7470            outcomes.push(Self::outcome_from_row(
7471                row.map_err(|error| error.to_string())?,
7472            )?);
7473        }
7474        Ok(outcomes)
7475    }
7476
7477    fn prune_commit_outcomes(&self) -> Result<(), String> {
7478        #[cfg(test)]
7479        self.outcome_prune_count
7480            .set(self.outcome_prune_count.get() + 1);
7481        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7482        let count = self
7483            .conn
7484            .query_row(
7485                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7486                [],
7487                |row| row.get::<_, i64>(0),
7488            )
7489            .map_err(|error| error.to_string())? as usize;
7490        let excess = count.saturating_sub(max_entries);
7491        if excess == 0 {
7492            return Ok(());
7493        }
7494        let mut stmt = self
7495            .conn
7496            .prepare(
7497                "SELECT seq FROM _syncular_commit_outcomes
7498                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
7499                  ORDER BY seq ASC LIMIT ?1",
7500            )
7501            .map_err(|error| error.to_string())?;
7502        let rows = stmt
7503            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7504            .map_err(|error| error.to_string())?;
7505        let sequences = rows
7506            .collect::<Result<Vec<_>, _>>()
7507            .map_err(|error| error.to_string())?;
7508        drop(stmt);
7509        for sequence in sequences {
7510            self.conn
7511                .execute(
7512                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7513                    rusqlite::params![sequence],
7514                )
7515                .map_err(|error| error.to_string())?;
7516        }
7517        Ok(())
7518    }
7519
7520    // -- driver surface ---------------------------------------------------------
7521
7522    pub fn subscribe(
7523        &mut self,
7524        id: String,
7525        table: String,
7526        scopes: Vec<(String, Vec<String>)>,
7527        params: Option<String>,
7528    ) -> Result<(), String> {
7529        if self.schema.table(&table).is_none() {
7530            return Err(format!("unknown table {table:?}"));
7531        }
7532        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7533            let same_intent = existing.table == table
7534                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7535                && existing.params == params;
7536            if same_intent {
7537                return Ok(());
7538            }
7539            return Err(
7540                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7541                    .to_owned(),
7542            );
7543        }
7544        let sub = Subscription {
7545            id: id.clone(),
7546            table,
7547            requested: scopes,
7548            params,
7549            cursor: -1,
7550            bootstrap_state: None,
7551            state: SubState::Active,
7552            reason_code: None,
7553            effective: None,
7554            synced_once: false,
7555        };
7556        self.persist_sub(&sub)?;
7557        self.subs.push(sub);
7558        Ok(())
7559    }
7560
7561    pub fn unsubscribe(&mut self, id: &str) {
7562        self.subs.retain(|s| s.id != id);
7563        let _ = self.conn.execute(
7564            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7565            rusqlite::params![id],
7566        );
7567    }
7568
7569    // -- windowed subscriptions (§4.8) ------------------------------------------
7570
7571    /// §4.8: set the live window units for a base — a value-sharded family
7572    /// of subscriptions, one per unit. Added units get fresh subscriptions
7573    /// (image-lane bootstrap on the next sync); removed units are
7574    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
7575    /// Idempotent; re-entry cancels any deferred eviction.
7576    pub fn set_window(
7577        &mut self,
7578        base: &WindowBase,
7579        units: &[String],
7580    ) -> Result<CommandEffects, String> {
7581        let table = self
7582            .schema
7583            .table(&base.table)
7584            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7585        if table.scope_column(&base.variable).is_none() {
7586            return Err(format!(
7587                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7588                base.table, base.variable
7589            ));
7590        }
7591        let base_key = window_base_key(base);
7592        let wanted: std::collections::HashSet<&String> = units.iter().collect();
7593        let live = self.load_window_units(&base_key);
7594        self.begin_observation("syncular_window")?;
7595        let mut batch = ChangeAccumulator::default();
7596        let mut changed = false;
7597
7598        let prior_subs = self.subs.clone();
7599        let widened = (|| {
7600            // Widen: units wanted but not live → fresh subscription + registry row.
7601            for unit in units {
7602                if live.iter().any(|(u, _)| u == unit) {
7603                    continue;
7604                }
7605                let sub_id = derive_sub_id(base, unit);
7606                self.delete_pending_evict(&sub_id)?;
7607                self.insert_window_unit(&base_key, unit, &sub_id)?;
7608                self.subscribe(
7609                    sub_id,
7610                    base.table.clone(),
7611                    unit_scopes(base, unit),
7612                    base.params.clone(),
7613                )?;
7614                batch.window(&base_key, &base.table, unit);
7615                changed = true;
7616            }
7617
7618            self.finish_observation("syncular_window", batch)
7619        })();
7620        if let Err(error) = widened {
7621            self.rollback_observation("syncular_window");
7622            self.subs = prior_subs;
7623            return Err(error);
7624        }
7625        for (unit, sub_id) in live {
7626            if wanted.contains(&unit) {
7627                continue;
7628            }
7629            self.rebuild_overlay_if_dirty();
7630            let prior_subs = self.subs.clone();
7631            self.begin_observation("syncular_window")?;
7632            let effective = self
7633                .subs
7634                .iter()
7635                .find(|sub| sub.id == sub_id)
7636                .and_then(|sub| sub.effective.clone())
7637                .unwrap_or_else(|| unit_scopes(base, &unit));
7638            let mut batch = ChangeAccumulator::default();
7639            self.record_scope_map(&mut batch, &base.table, &effective);
7640            batch.window(&base_key, &base.table, &unit);
7641            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7642            let remaining = match result {
7643                Ok(remaining) => remaining,
7644                Err(error) => {
7645                    self.rollback_observation("syncular_window");
7646                    self.subs = prior_subs;
7647                    return Err(error);
7648                }
7649            };
7650            if let Err(error) = self.finish_observation("syncular_window", batch) {
7651                self.rollback_observation("syncular_window");
7652                self.subs = prior_subs;
7653                return Err(error);
7654            }
7655            if remaining {
7656                self.drain_pending_evictions()?;
7657            }
7658            changed = true;
7659        }
7660        Ok(if changed {
7661            CommandEffects::interactive()
7662        } else {
7663            CommandEffects::none()
7664        })
7665    }
7666
7667    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
7668    /// the subset still bootstrap-pending. Registration alone is not
7669    /// completeness — a unit is pending until its subscription completes a
7670    /// bootstrap round (cursor advances past -1 with no resume token held).
7671    pub fn window_state(&self, base: &WindowBase) -> WindowState {
7672        let mut units = Vec::new();
7673        let mut pending = Vec::new();
7674        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7675            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7676                Some(sub) => {
7677                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7678                }
7679                None => true,
7680            };
7681            if is_pending {
7682                pending.push(unit.clone());
7683            }
7684            units.push(unit);
7685        }
7686        WindowState { units, pending }
7687    }
7688
7689    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7690        let mut stmt = match self
7691            .conn
7692            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7693        {
7694            Ok(stmt) => stmt,
7695            Err(_) => return Vec::new(),
7696        };
7697        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7698            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7699        });
7700        match rows {
7701            Ok(rows) => rows.filter_map(Result::ok).collect(),
7702            Err(_) => Vec::new(),
7703        }
7704    }
7705
7706    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7707        let mut stmt = match self.conn.prepare(
7708            "SELECT windows.base, windows.unit, subscriptions.tbl
7709               FROM _syncular_windows AS windows
7710               JOIN _syncular_subscriptions AS subscriptions
7711                 ON subscriptions.id = windows.sub_id
7712               ORDER BY windows.base, windows.unit",
7713        ) {
7714            Ok(stmt) => stmt,
7715            Err(_) => return Vec::new(),
7716        };
7717        let rows = stmt.query_map([], |row| {
7718            Ok((
7719                row.get::<_, String>(0)?,
7720                row.get::<_, String>(1)?,
7721                row.get::<_, String>(2)?,
7722            ))
7723        });
7724        match rows {
7725            Ok(rows) => rows.filter_map(Result::ok).collect(),
7726            Err(_) => Vec::new(),
7727        }
7728    }
7729
7730    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
7731        self.conn
7732            .query_row(
7733                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
7734                rusqlite::params![sub_id],
7735                |row| Ok((row.get(0)?, row.get(1)?)),
7736            )
7737            .ok()
7738    }
7739
7740    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
7741        self.conn
7742            .execute(
7743                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
7744                rusqlite::params![base_key, unit, sub_id],
7745            )
7746            .map(|_| ())
7747            .map_err(|error| error.to_string())
7748    }
7749
7750    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
7751        self.conn
7752            .execute(
7753                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
7754                rusqlite::params![base_key, unit],
7755            )
7756            .map(|_| ())
7757            .map_err(|error| error.to_string())
7758    }
7759
7760    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
7761    /// Deletes the unit's rows except those pinned by a pending outbox
7762    /// commit (E1); records a deferred eviction if any pin remains; discards
7763    /// the subscription's cursor/resume/effective-echo (E3) and version
7764    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
7765    /// nothing.
7766    fn evict_unit(
7767        &mut self,
7768        base_key: &str,
7769        base: &WindowBase,
7770        unit: &str,
7771        sub_id: &str,
7772    ) -> Result<bool, String> {
7773        let effective = self
7774            .subs
7775            .iter()
7776            .find(|s| s.id == sub_id)
7777            .and_then(|s| s.effective.clone())
7778            .unwrap_or_else(|| unit_scopes(base, unit));
7779        let pinned = self.pinned_row_ids(&base.table);
7780        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
7781        self.delete_window_unit(base_key, unit)?;
7782        self.conn
7783            .execute(
7784                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7785                [sub_id],
7786            )
7787            .map_err(|error| error.to_string())?;
7788        self.subs.retain(|sub| sub.id != sub_id);
7789        if deferred {
7790            self.save_pending_evict(sub_id, &base.table, &effective)?;
7791        } else {
7792            self.delete_pending_evict(sub_id)?;
7793        }
7794        Ok(remaining)
7795    }
7796
7797    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
7798    /// primary keys. Returns (more unpinned work, any scoped rows remain).
7799    /// A missing scope-column mapping fails without deleting rows.
7800    fn evict_scope_rows(
7801        &self,
7802        table_name: &str,
7803        effective: &[(String, Vec<String>)],
7804        pinned: &std::collections::HashSet<String>,
7805    ) -> Result<(bool, bool), String> {
7806        if effective.is_empty() {
7807            return Ok((false, false));
7808        }
7809        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
7810        let mut clauses = Vec::new();
7811        let mut params: Vec<SqlValue> = Vec::new();
7812        for (variable, values) in effective {
7813            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
7814            if values.is_empty() {
7815                return Ok((false, false));
7816            }
7817            let holes = values
7818                .iter()
7819                .map(|v| {
7820                    params.push(SqlValue::Text(v.clone()));
7821                    "?"
7822                })
7823                .collect::<Vec<_>>()
7824                .join(", ");
7825            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
7826        }
7827        let scope = clauses.join(" AND ");
7828        let scope_params = params.len();
7829        let pk = quote_ident(&table.primary_key);
7830        if !pinned.is_empty() {
7831            let holes = pinned
7832                .iter()
7833                .map(|id| {
7834                    params.push(SqlValue::Text(id.clone()));
7835                    "?"
7836                })
7837                .collect::<Vec<_>>()
7838                .join(", ");
7839            clauses.push(format!("{pk} NOT IN ({holes})"));
7840        }
7841        let predicate = clauses.join(" AND ");
7842        let base = base_table(table_name);
7843        let visible = visible_table(table_name);
7844        let mut ids: Vec<SqlValue> = Vec::new();
7845        for source in [&base, &visible] {
7846            let mut stmt = self
7847                .conn
7848                .prepare_cached(&format!(
7849                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
7850                ))
7851                .map_err(|e| e.to_string())?;
7852            ids = stmt
7853                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
7854                .map_err(|e| e.to_string())?
7855                .collect::<Result<_, _>>()
7856                .map_err(|e| e.to_string())?;
7857            if !ids.is_empty() {
7858                break;
7859            }
7860        }
7861        if !ids.is_empty() {
7862            let holes = vec!["?"; ids.len()].join(", ");
7863            for source in [&base, &visible] {
7864                self.conn
7865                    .execute(
7866                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
7867                        rusqlite::params_from_iter(&ids),
7868                    )
7869                    .map_err(|e| e.to_string())?;
7870            }
7871        }
7872        let mut remaining = false;
7873        let mut deferred = false;
7874        for source in [&base, &visible] {
7875            remaining |= self
7876                .conn
7877                .query_row(
7878                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
7879                    rusqlite::params_from_iter(&params),
7880                    |row| row.get::<_, bool>(0),
7881                )
7882                .map_err(|e| e.to_string())?;
7883            deferred |= self
7884                .conn
7885                .query_row(
7886                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
7887                    rusqlite::params_from_iter(&params[..scope_params]),
7888                    |row| row.get::<_, bool>(0),
7889                )
7890                .map_err(|e| e.to_string())?;
7891        }
7892        Ok((remaining, deferred))
7893    }
7894
7895    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
7896    fn drain_pending_evictions(&mut self) -> Result<(), String> {
7897        let pending = self.load_pending_evictions()?;
7898        if pending.is_empty() {
7899            return Ok(());
7900        }
7901        self.rebuild_overlay_if_dirty();
7902        for (sub_id, table_name, effective) in pending {
7903            if self.schema.table(&table_name).is_none() {
7904                self.delete_pending_evict(&sub_id)?;
7905                continue;
7906            }
7907            loop {
7908                std::thread::yield_now();
7909                self.begin_observation("syncular_evict")?;
7910                let pinned = self.pinned_row_ids(&table_name);
7911                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
7912                let (remaining, deferred) = match result {
7913                    Ok(result) => result,
7914                    Err(error) => {
7915                        self.rollback_observation("syncular_evict");
7916                        return Err(error);
7917                    }
7918                };
7919                if !deferred {
7920                    if let Err(error) = self.delete_pending_evict(&sub_id) {
7921                        self.rollback_observation("syncular_evict");
7922                        return Err(error);
7923                    }
7924                }
7925                let mut batch = ChangeAccumulator::default();
7926                self.record_scope_map(&mut batch, &table_name, &effective);
7927                if let Err(error) = self.finish_observation("syncular_evict", batch) {
7928                    self.rollback_observation("syncular_evict");
7929                    return Err(error);
7930                }
7931                if !remaining {
7932                    break;
7933                }
7934            }
7935        }
7936        Ok(())
7937    }
7938
7939    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
7940    /// commit — rows that MUST NOT be evicted until the commit drains.
7941    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
7942        let mut pinned = std::collections::HashSet::new();
7943        for commit in &self.outbox {
7944            for op in &commit.ops {
7945                if op.table == table {
7946                    pinned.insert(op.row_id.clone());
7947                }
7948            }
7949        }
7950        pinned
7951    }
7952
7953    fn save_pending_evict(
7954        &self,
7955        sub_id: &str,
7956        table: &str,
7957        effective: &[(String, Vec<String>)],
7958    ) -> Result<(), String> {
7959        self.conn.execute(
7960            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
7961            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
7962        ).map(|_| ()).map_err(|error| error.to_string())
7963    }
7964
7965    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
7966        self.conn
7967            .execute(
7968                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7969                rusqlite::params![sub_id],
7970            )
7971            .map(|_| ())
7972            .map_err(|error| error.to_string())
7973    }
7974
7975    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
7976        let mut stmt = self
7977            .conn
7978            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
7979            .map_err(|error| error.to_string())?;
7980        let rows = stmt
7981            .query_map([], |row| {
7982                Ok((
7983                    row.get::<_, String>(0)?,
7984                    row.get::<_, String>(1)?,
7985                    row.get::<_, String>(2)?,
7986                ))
7987            })
7988            .map_err(|error| error.to_string())?;
7989        rows.map(|row| {
7990            let (sub_id, table, json) = row.map_err(|error| error.to_string())?;
7991            let value: Value = serde_json::from_str(&json)
7992                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7993            let effective = json_to_scope_map(&value)
7994                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7995            Ok((sub_id, table, effective))
7996        })
7997        .collect()
7998    }
7999
8000    /// Record one atomic local commit (§7.1) and apply it optimistically.
8001    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
8002        if mutations.is_empty() {
8003            return Err("a commit must contain at least one operation (§6.1)".to_owned());
8004        }
8005        let mut ops = Vec::with_capacity(mutations.len());
8006        for mutation in mutations {
8007            match mutation {
8008                Mutation::Upsert {
8009                    table,
8010                    values,
8011                    base_version,
8012                } => {
8013                    let schema_table = self
8014                        .schema
8015                        .table(&table)
8016                        .ok_or_else(|| format!("unknown table {table:?}"))?;
8017                    // §5: value keys are accepted in snake_case AND the
8018                    // generated row types' camelCase; normalize to SQL truth
8019                    // before the pk lookup / codec see them.
8020                    let values = normalize_values_casing(schema_table, values)?;
8021                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8022                    // §6.7 `mutate` marks every column present: fill missing
8023                    // nullable columns with NULL before the payload is built.
8024                    let values = full_row_values(schema_table, values)?;
8025                    // §5.11: validate the payload encodes with the current
8026                    // codec. A key-selection or unknown-key failure is a
8027                    // durable rejection at the push seam (§10.3), never an
8028                    // author-time error. Full-row `mutate` presents every
8029                    // column, so no stored fallback is needed.
8030                    self.validate_author_encode(schema_table, &row_id, &values)?;
8031                    ops.push(OutboxOp {
8032                        upsert: true,
8033                        table,
8034                        row_id,
8035                        base_version,
8036                        values: Some(values),
8037                    });
8038                }
8039                Mutation::Delete {
8040                    table,
8041                    row_id,
8042                    base_version,
8043                } => {
8044                    if self.schema.table(&table).is_none() {
8045                        return Err(format!("unknown table {table:?}"));
8046                    }
8047                    ops.push(OutboxOp {
8048                        upsert: false,
8049                        table,
8050                        row_id,
8051                        base_version,
8052                        values: None,
8053                    });
8054                }
8055            }
8056        }
8057        self.record_outbox_commit(ops)
8058    }
8059
8060    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
8061        let commit = OutboxCommit {
8062            client_commit_id: uuid::Uuid::new_v4().to_string(),
8063            ops,
8064        };
8065        self.begin_observation("syncular_mutation")?;
8066        let mut batch = ChangeAccumulator::default();
8067        for op in &commit.ops {
8068            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
8069            if let Some(values) = &op.values {
8070                if let Some(table) = self.schema.table(&op.table) {
8071                    for scope in &table.scope_variables {
8072                        if let Some(Value::String(value)) = values.get(&scope.column) {
8073                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
8074                            precise = true;
8075                        }
8076                    }
8077                }
8078            }
8079            if !precise {
8080                batch.table(&op.table);
8081            }
8082        }
8083        if let Err(error) = self.persist_outbox_insert(&commit) {
8084            self.rollback_observation("syncular_mutation");
8085            return Err(error);
8086        }
8087        let was_dirty = self.overlay_dirty.get();
8088        // A clean overlay contains the FIFO fold of every previous commit, so
8089        // the new commit applies over it. §7.1: an operation that does not
8090        // apply (a secondary unique collision) fails the whole commit; the
8091        // savepoint rollback removes the outbox entry and every visible write.
8092        if was_dirty {
8093            self.rebuild_overlay();
8094        }
8095        if let Some(error) = commit
8096            .ops
8097            .iter()
8098            .find_map(|op| self.apply_outbox_op(op).err())
8099        {
8100            self.rollback_observation("syncular_mutation");
8101            self.overlay_dirty.set(was_dirty);
8102            return Err(error);
8103        }
8104        let id = commit.client_commit_id.clone();
8105        self.outbox.push(commit);
8106        batch.status = true;
8107        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
8108            self.rollback_observation("syncular_mutation");
8109            self.outbox.pop();
8110            self.overlay_dirty.set(was_dirty);
8111            return Err(error);
8112        }
8113        Ok(id)
8114    }
8115
8116    /// Record one §6.1 sparse upsert: the primary key plus the supplied
8117    /// non-scope columns are present; every other column is absent and stays
8118    /// untouched on the server and in the local overlay. The row need not be
8119    /// locally present — an absent local row leaves the overlay absent (§7.1)
8120    /// and the server answers per §5.2.
8121    pub fn patch(
8122        &mut self,
8123        table: &str,
8124        row_id: &str,
8125        partial: Map<String, Value>,
8126        base_version: Option<i64>,
8127    ) -> Result<String, String> {
8128        let schema_table = self
8129            .schema
8130            .table(table)
8131            .ok_or_else(|| format!("unknown table {table:?}"))?;
8132        let partial = normalize_values_casing(schema_table, partial)?;
8133        if let Some(pk) = partial.get(&schema_table.primary_key) {
8134            if pk.as_str() != Some(row_id) {
8135                return Err(format!(
8136                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
8137                ));
8138            }
8139        }
8140        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
8141        // server accepts a present scope column whose value equals the
8142        // stored row and applies it as a no-op, so a decoded envelope
8143        // round-trips. The local row is the only value the client can prove
8144        // equality against; when the row is absent locally the client cannot
8145        // prove equality against the server's stored row and fails closed.
8146        // A primary key that is also a scope column needs no stored-row
8147        // read: the primary key in a sparse payload is by construction the
8148        // row id being patched (§6.1), so its value is proven equal already.
8149        for scope in &schema_table.scope_variables {
8150            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
8151                continue;
8152            }
8153            let Some(column) = schema_table
8154                .columns
8155                .iter()
8156                .find(|column| column.name == scope.column)
8157            else {
8158                continue;
8159            };
8160            let stored = self
8161                .conn
8162                .query_row(
8163                    &format!(
8164                        "SELECT {} FROM {} WHERE {}",
8165                        quote_ident(&scope.column),
8166                        visible_table(&schema_table.name),
8167                        row_id_predicate(schema_table)
8168                    ),
8169                    rusqlite::params![row_id],
8170                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
8171                )
8172                .ok();
8173            // Decode the supplied value through the same column-type path the
8174            // rest of the patch encoding uses, so a value that only equals
8175            // the stored one in another number representation still compares
8176            // equal.
8177            let incoming = json_to_column_value(column, partial.get(&scope.column))
8178                .ok()
8179                .map(|value| column_value_to_json(&value));
8180            // A stored blob never equals a patch value, matching the server's
8181            // scope-column comparison (§3.4): bytes are unstorable as scope
8182            // values.
8183            let equal = stored.as_ref().is_some_and(|stored| {
8184                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
8185            });
8186            if !equal {
8187                return Err(format!(
8188                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
8189                    scope.column
8190                ));
8191            }
8192        }
8193        let mut values = partial;
8194        // Presence-set semantics: a proven-equal scope column is dropped, so
8195        // the patch leaves the stored value untouched (§6.2).
8196        for scope in &schema_table.scope_variables {
8197            values.remove(&scope.column);
8198        }
8199        values.insert(
8200            schema_table.primary_key.clone(),
8201            Value::from(row_id.to_owned()),
8202        );
8203        // §5.11: structural validation only; an absent key-id selector is
8204        // resolved at the push seam from the stored local row, and a
8205        // key-selection or unknown-key failure becomes a durable
8206        // `client.encrypt_failed` rejection there (§10.3), never an
8207        // author-time error.
8208        self.validate_author_encode(schema_table, row_id, &values)?;
8209        self.record_outbox_commit(vec![OutboxOp {
8210            upsert: true,
8211            table: table.to_owned(),
8212            row_id: row_id.to_owned(),
8213            base_version,
8214            values: Some(values),
8215        }])
8216    }
8217
8218    /// §5.11 key-id fallback: the stored local row's selector value as a
8219    /// positional row (only the selector slot filled). Returns None when no
8220    /// selector is configured, the row is locally absent, or the stored
8221    /// value is not a usable key id.
8222    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
8223        let selector = self.encryption.key_id_columns.get(&table.name)?;
8224        let index = table
8225            .columns
8226            .iter()
8227            .position(|column| &column.name == selector)?;
8228        let sql = format!(
8229            "SELECT {} FROM {} WHERE {}",
8230            quote_ident(selector),
8231            visible_table(&table.name),
8232            row_id_predicate(table)
8233        );
8234        let key: Option<String> = self
8235            .conn
8236            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
8237            .ok()?;
8238        let key = key.filter(|key| !key.is_empty())?;
8239        let mut fallback: Row = vec![None; table.columns.len()];
8240        fallback[index] = Some(ColumnValue::String(key));
8241        Some(fallback)
8242    }
8243
8244    /// §5.11/§10.3 author-time encode check: a structural failure (a
8245    /// non-nullable NULL, a value the codec rejects) is an author-time
8246    /// error; a key-selection or unknown-key failure is deferred to the push
8247    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
8248    fn validate_author_encode(
8249        &self,
8250        table: &TableSchema,
8251        row_id: &str,
8252        values: &Map<String, Value>,
8253    ) -> Result<(), String> {
8254        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
8255            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
8256            result => result.map(|_| ()),
8257        }
8258    }
8259
8260    /// §5.11/§10.3: drop every pending commit whose push encode fails with
8261    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
8262    /// raise one durable rejection per commit. `sync()` never aborts for an
8263    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
8264    /// undoing the dropped commit's purely-optimistic rows.
8265    fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
8266        if !self
8267            .schema
8268            .tables
8269            .iter()
8270            .any(|table| table.has_encrypted_columns())
8271        {
8272            return Ok(false);
8273        }
8274        let failures = self
8275            .outbox
8276            .iter()
8277            .filter_map(|commit| {
8278                commit
8279                    .ops
8280                    .iter()
8281                    .find_map(|op| {
8282                        let values = op.values.as_ref().filter(|_| op.upsert)?;
8283                        let table = self.schema.table(&op.table)?;
8284                        let fallback = self.stored_key_fallback(table, &op.row_id);
8285                        match encode_sparse_row_json(
8286                            table,
8287                            &op.row_id,
8288                            values,
8289                            &self.encryption,
8290                            fallback.as_ref(),
8291                        ) {
8292                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
8293                            _ => None,
8294                        }
8295                    })
8296                    .map(|error| (commit.clone(), error))
8297            })
8298            .collect::<Vec<_>>();
8299        if failures.is_empty() {
8300            return Ok(false);
8301        }
8302        for (commit, message) in failures {
8303            let rejection = RejectionRecord {
8304                client_commit_id: commit.client_commit_id.clone(),
8305                op_index: 0,
8306                code: ENCRYPT_FAILED_CODE.to_owned(),
8307                message,
8308                retryable: false,
8309                details: None,
8310                operation: commit.ops.first().map(CommitOperation::from),
8311            };
8312            self.persist_commit_outcome(
8313                &commit.client_commit_id,
8314                CommitOutcomeStatus::Rejected,
8315                &[CommitOperationOutcome::Error {
8316                    rejection: rejection.clone(),
8317                }],
8318                Some(&commit.ops),
8319            )?;
8320            self.delete_outbox_persisted(&commit.client_commit_id)?;
8321            self.outbox
8322                .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
8323            self.rejections.push(rejection);
8324        }
8325        self.prune_commit_outcomes()?;
8326        self.overlay_dirty.set(true);
8327        self.rebuild_overlay();
8328        Ok(true)
8329    }
8330
8331    pub fn pending_commit_ids(&self) -> Vec<String> {
8332        self.outbox
8333            .iter()
8334            .map(|c| c.client_commit_id.clone())
8335            .collect()
8336    }
8337
8338    /// §6.1: the sparse push payload bytes of every pending upsert operation,
8339    /// FIFO — exactly what the next push round would put on the wire. Used by
8340    /// conformance B.21(h) to pin cross-core byte identity.
8341    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
8342        let mut payloads = Vec::new();
8343        for commit in &self.outbox {
8344            for op in &commit.ops {
8345                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
8346                    continue;
8347                };
8348                let Some(table) = self.schema.table(&op.table) else {
8349                    continue;
8350                };
8351                let fallback = self.stored_key_fallback(table, &op.row_id);
8352                if let Ok(payload) = encode_sparse_row_json(
8353                    table,
8354                    &op.row_id,
8355                    values,
8356                    &self.encryption,
8357                    fallback.as_ref(),
8358                ) {
8359                    payloads.push(payload);
8360                }
8361            }
8362        }
8363        payloads
8364    }
8365
8366    pub fn conflicts(&self) -> &[ConflictRecord] {
8367        &self.conflicts
8368    }
8369
8370    pub fn rejections(&self) -> &[RejectionRecord] {
8371        &self.rejections
8372    }
8373
8374    pub fn resolve_commit_outcome(
8375        &mut self,
8376        input: ResolveCommitOutcomeInput,
8377    ) -> Result<CommitOutcome, String> {
8378        let current = self
8379            .commit_outcome(&input.client_commit_id)?
8380            .ok_or_else(|| {
8381                format!(
8382                    "sync.outcome_not_found: no durable outcome exists for {:?}",
8383                    input.client_commit_id
8384                )
8385            })?;
8386        if current.resolution != CommitOutcomeResolution::Active {
8387            return Ok(current);
8388        }
8389        if input.resolution == CommitOutcomeResolution::Active {
8390            return Err("sync.invalid_request: resolution must leave active state".to_owned());
8391        }
8392        match input.resolution {
8393            CommitOutcomeResolution::Superseded => {
8394                let replacement = input
8395                    .replacement_client_commit_id
8396                    .as_deref()
8397                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8398                    .ok_or_else(|| {
8399                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8400                            .to_owned()
8401                    })?;
8402                let _ = replacement;
8403            }
8404            _ if input.replacement_client_commit_id.is_some() => {
8405                return Err(
8406                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8407                        .to_owned(),
8408                );
8409            }
8410            _ => {}
8411        }
8412        let allowed = match current.status {
8413            CommitOutcomeStatus::Conflict => matches!(
8414                input.resolution,
8415                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8416            ),
8417            CommitOutcomeStatus::Rejected => {
8418                input.resolution == CommitOutcomeResolution::Superseded
8419            }
8420            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8421                input.resolution == CommitOutcomeResolution::Dismissed
8422            }
8423        };
8424        if !allowed {
8425            return Err(format!(
8426                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8427                input.resolution, current.status
8428            ));
8429        }
8430
8431        self.begin_observation("syncular_outcome_resolution")?;
8432        let result = (|| {
8433            self.conn
8434                .execute(
8435                    "UPDATE _syncular_commit_outcomes
8436                        SET resolution = ?1, resolved_at_ms = ?2,
8437                            replacement_client_commit_id = ?3
8438                      WHERE client_commit_id = ?4 AND resolution = 'active'",
8439                    rusqlite::params![
8440                        Self::outcome_resolution_name(input.resolution),
8441                        self.clock_now_ms(),
8442                        input.replacement_client_commit_id,
8443                        input.client_commit_id
8444                    ],
8445                )
8446                .map_err(|error| error.to_string())?;
8447            let resolved = self
8448                .commit_outcome(&current.client_commit_id)?
8449                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8450            self.prune_commit_outcomes()?;
8451            Ok(resolved)
8452        })();
8453        let resolved = match result {
8454            Ok(outcome) => outcome,
8455            Err(error) => {
8456                self.rollback_observation("syncular_outcome_resolution");
8457                return Err(error);
8458            }
8459        };
8460        self.conflicts
8461            .retain(|record| record.client_commit_id != current.client_commit_id);
8462        self.rejections
8463            .retain(|record| record.client_commit_id != current.client_commit_id);
8464        let batch = ChangeAccumulator {
8465            conflicts: current.status == CommitOutcomeStatus::Conflict,
8466            rejections: current.status == CommitOutcomeStatus::Rejected,
8467            outcomes: true,
8468            ..ChangeAccumulator::default()
8469        };
8470        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8471            self.rollback_observation("syncular_outcome_resolution");
8472            return Err(error);
8473        }
8474        Ok(resolved)
8475    }
8476
8477    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8478        self.schema_floor.as_ref()
8479    }
8480
8481    /// §7.3.5: the client's opaque auth-lease state, if any.
8482    pub fn lease_state(&self) -> Option<&LeaseState> {
8483        self.lease_state.as_ref()
8484    }
8485
8486    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
8487    /// the two lease codes set it; other errors leave leaseState untouched.
8488    fn record_lease_error(&mut self, code: &str) {
8489        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8490            return;
8491        }
8492        let mut next = self.lease_state.clone().unwrap_or_default();
8493        next.error_code = Some(code.to_owned());
8494        self.set_lease_state(Some(next));
8495    }
8496
8497    fn set_lease_state(&mut self, next: Option<LeaseState>) {
8498        if self.lease_state == next {
8499            return;
8500        }
8501        if self.begin_observation("syncular_lease").is_err() {
8502            return;
8503        }
8504        self.lease_state = next;
8505        if let Some(lease) = &self.lease_state {
8506            if let Ok(json) = serde_json::to_string(lease) {
8507                self.set_meta(LEASE_STATE_KEY, &json);
8508            }
8509        } else {
8510            self.delete_meta(LEASE_STATE_KEY);
8511        }
8512        let batch = ChangeAccumulator {
8513            status: true,
8514            ..ChangeAccumulator::default()
8515        };
8516        if self.finish_observation("syncular_lease", batch).is_err() {
8517            self.rollback_observation("syncular_lease");
8518        }
8519    }
8520
8521    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
8522        if self.schema_floor == next {
8523            return;
8524        }
8525        if self.begin_observation("syncular_schema_floor").is_err() {
8526            return;
8527        }
8528        self.schema_floor = next;
8529        self.stopped = self.schema_floor.is_some();
8530        if let Some(floor) = &self.schema_floor {
8531            if let Ok(json) = serde_json::to_string(floor) {
8532                self.set_meta(SCHEMA_FLOOR_KEY, &json);
8533            }
8534        } else {
8535            self.delete_meta(SCHEMA_FLOOR_KEY);
8536        }
8537        let batch = ChangeAccumulator {
8538            status: true,
8539            ..ChangeAccumulator::default()
8540        };
8541        if self
8542            .finish_observation("syncular_schema_floor", batch)
8543            .is_err()
8544        {
8545            self.rollback_observation("syncular_schema_floor");
8546        }
8547    }
8548
8549    fn set_upgrading(&mut self, value: bool) {
8550        if self.upgrading == value {
8551            return;
8552        }
8553        if self.begin_observation("syncular_upgrading").is_err() {
8554            return;
8555        }
8556        self.upgrading = value;
8557        let batch = ChangeAccumulator {
8558            status: true,
8559            ..ChangeAccumulator::default()
8560        };
8561        if self
8562            .finish_observation("syncular_upgrading", batch)
8563            .is_err()
8564        {
8565            self.rollback_observation("syncular_upgrading");
8566        }
8567    }
8568
8569    pub fn sync_needed(&self) -> bool {
8570        self.sync_needed
8571    }
8572
8573    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
8574        let sub = self.subs.iter().find(|s| s.id == id)?;
8575        Some(SubscriptionStateView {
8576            id: sub.id.clone(),
8577            table: sub.table.clone(),
8578            status: sub.state.name().to_owned(),
8579            cursor: sub.cursor,
8580            has_resume_token: sub.bootstrap_state.is_some(),
8581            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8582            reason_code: sub.reason_code.clone(),
8583        })
8584    }
8585
8586    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
8587        let schema_table = self
8588            .schema
8589            .table(table)
8590            .ok_or_else(|| format!("unknown table {table:?}"))?;
8591        let sql = format!(
8592            "SELECT * FROM {} ORDER BY {} ASC",
8593            visible_table(table),
8594            quote_ident(&schema_table.primary_key)
8595        );
8596        let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
8597        let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
8598        let mut out = Vec::new();
8599        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
8600            let mut values = Map::new();
8601            for (i, column) in schema_table.columns.iter().enumerate() {
8602                let value = row.get_ref(i).map_err(|e| e.to_string())?;
8603                values.insert(column.name.clone(), sql_ref_to_json(column, value));
8604            }
8605            let version: i64 = row
8606                .get(schema_table.columns.len())
8607                .map_err(|e| e.to_string())?;
8608            let row_id = match values.get(&schema_table.primary_key) {
8609                Some(Value::String(s)) => s.clone(),
8610                Some(Value::Number(n)) => n.to_string(),
8611                Some(Value::Bool(b)) => b.to_string(),
8612                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
8613            };
8614            out.push(RowState {
8615                row_id,
8616                version,
8617                values,
8618            });
8619        }
8620        Ok(out)
8621    }
8622
8623    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
8624    //
8625    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
8626    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
8627    // the op with `yrs`, re-encodes the whole doc state, and records a
8628    // baseVersion-less sparse upsert carrying only the primary key and the
8629    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
8630    // the overlay re-materializes the edit immediately (optimistic apply,
8631    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
8632    // Byte-compatible with `@syncular/crdt-yjs`.
8633
8634    /// The current stored value of a `crdt` column for one row — the visible
8635    /// (optimistic) bytes, or `None` when the row is absent or the column is
8636    /// NULL (the empty document, §5.10.1). Errors if the column is not a
8637    /// `crdt` column (guards the app against a typo'd column name).
8638    #[cfg(feature = "crdt-yjs")]
8639    fn crdt_column_bytes(
8640        &self,
8641        table: &str,
8642        row_id: &str,
8643        column: &str,
8644    ) -> Result<Option<Vec<u8>>, String> {
8645        let schema_table = self
8646            .schema
8647            .table(table)
8648            .ok_or_else(|| format!("unknown table {table:?}"))?;
8649        let col = schema_table
8650            .columns
8651            .iter()
8652            .find(|c| c.name == column)
8653            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
8654        if col.ty != ColumnType::Crdt {
8655            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
8656        }
8657        let sql = format!(
8658            "SELECT {} FROM {} WHERE {}",
8659            quote_ident(column),
8660            visible_table(table),
8661            row_id_predicate(schema_table)
8662        );
8663        let bytes: Option<Vec<u8>> = self
8664            .conn
8665            .prepare_cached(&sql)
8666            .map_err(|error| error.to_string())?
8667            .query_row(rusqlite::params![row_id], |row| {
8668                row.get::<_, Option<Vec<u8>>>(0)
8669            })
8670            .map_err(|e| match e {
8671                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
8672                other => other.to_string(),
8673            })?;
8674        Ok(bytes)
8675    }
8676
8677    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
8678    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
8679    /// An absent row / NULL column is the empty document (empty string).
8680    #[cfg(feature = "crdt-yjs")]
8681    pub fn crdt_text(
8682        &self,
8683        table: &str,
8684        row_id: &str,
8685        column: &str,
8686        name: &str,
8687    ) -> Result<String, String> {
8688        let bytes = self
8689            .crdt_column_bytes(table, row_id, column)?
8690            .unwrap_or_default();
8691        crate::crdt::text(&bytes, name)
8692    }
8693
8694    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
8695    /// record the resulting crdt-only sparse operation. Returns the enqueued
8696    /// `clientCommitId`.
8697    #[cfg(feature = "crdt-yjs")]
8698    pub fn crdt_insert_text(
8699        &mut self,
8700        table: &str,
8701        row_id: &str,
8702        column: &str,
8703        name: &str,
8704        index: u32,
8705        value: &str,
8706    ) -> Result<String, String> {
8707        let current = self
8708            .crdt_column_bytes(table, row_id, column)?
8709            .unwrap_or_default();
8710        let update = crate::crdt::insert_text(&current, name, index, value)?;
8711        self.crdt_push_update(table, row_id, column, &update)
8712    }
8713
8714    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
8715    /// record the resulting crdt-only sparse operation. Returns the enqueued
8716    /// `clientCommitId`.
8717    #[cfg(feature = "crdt-yjs")]
8718    pub fn crdt_delete_text(
8719        &mut self,
8720        table: &str,
8721        row_id: &str,
8722        column: &str,
8723        name: &str,
8724        index: u32,
8725        len: u32,
8726    ) -> Result<String, String> {
8727        let current = self
8728            .crdt_column_bytes(table, row_id, column)?
8729            .unwrap_or_default();
8730        let update = crate::crdt::delete_text(&current, name, index, len)?;
8731        self.crdt_push_update(table, row_id, column, &update)
8732    }
8733
8734    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
8735    /// `crdt` column's current state and record the resulting crdt-only sparse
8736    /// operation. The app authored the update with its own `yrs` model.
8737    /// Returns the enqueued `clientCommitId`.
8738    #[cfg(feature = "crdt-yjs")]
8739    pub fn crdt_apply_update(
8740        &mut self,
8741        table: &str,
8742        row_id: &str,
8743        column: &str,
8744        update: &[u8],
8745    ) -> Result<String, String> {
8746        let current = self
8747            .crdt_column_bytes(table, row_id, column)?
8748            .unwrap_or_default();
8749        let next = crate::crdt::apply_update(&current, update)?;
8750        self.crdt_push_update(table, row_id, column, &next)
8751    }
8752
8753    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
8754    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
8755    /// enqueue it. The server merges the crdt column and leaves every other
8756    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
8757    /// clobber a concurrent edit to another column. A locally absent row
8758    /// records the same partial operation; the server answers per §6.2.
8759    #[cfg(feature = "crdt-yjs")]
8760    fn crdt_push_update(
8761        &mut self,
8762        table: &str,
8763        row_id: &str,
8764        column: &str,
8765        crdt_bytes: &[u8],
8766    ) -> Result<String, String> {
8767        let mut bytes_obj = Map::new();
8768        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
8769        let mut partial = Map::new();
8770        partial.insert(column.to_owned(), Value::Object(bytes_obj));
8771        self.patch(table, row_id, partial, None)
8772    }
8773
8774    /// Run an arbitrary read-only SQL query against the local database and
8775    /// return each row as a `column-name → JSON value` map. This is the seam
8776    /// the React `useSyncQuery` live-query API needs (it takes app-authored
8777    /// SQL over the visible tables/views, not a fixed table read like
8778    /// [`read_rows`]).
8779    ///
8780    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
8781    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
8782    /// same envelope the command surface uses everywhere else. Output BLOB
8783    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
8784    ///
8785    /// The result column typing is dynamic (SQLite's stored affinity), because
8786    /// arbitrary SQL can alias, join, and compute — there is no schema column
8787    /// to consult per output cell, unlike [`read_rows`].
8788    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
8789        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
8790    }
8791
8792    /// Repository benchmark and conformance access, present only with `bench-internals`.
8793    #[cfg(feature = "bench-internals")]
8794    #[doc(hidden)]
8795    pub fn benchmark_connection(&mut self) -> &mut Connection {
8796        &mut self.conn
8797    }
8798
8799    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
8800    #[cfg(feature = "bench-internals")]
8801    #[doc(hidden)]
8802    pub fn benchmark_phases(
8803        &self,
8804        enabled: Option<bool>,
8805        reset: bool,
8806    ) -> Result<Option<Value>, String> {
8807        self.benchmark_phases.configure(enabled, reset)?;
8808        Ok(self.benchmark_phases.snapshot())
8809    }
8810
8811    /// Rows, coverage, and local revision from one SQLite read snapshot.
8812    /// §7.5 atomic snapshot read. An `owner` records a failure in
8813    /// `diagnostics_snapshot().query_failures` until its next successful read.
8814    pub fn query_snapshot(
8815        &mut self,
8816        sql: &str,
8817        params: &[QueryValue],
8818        coverage: &[WindowCoverage],
8819        owner: Option<QueryOwner<'_>>,
8820    ) -> Result<QuerySnapshot, String> {
8821        let result = snapshot_connection(&self.conn, sql, params, coverage);
8822        if let Some(owner) = owner {
8823            self.record_query_read(owner, result.as_ref().err());
8824        }
8825        result.map_err(|failure| failure.to_string())
8826    }
8827
8828    /// §7.6: record the outcome of one owned snapshot read, including reads a
8829    /// host ran on a separate connection. A success removes the owner's entry;
8830    /// an identical failure leaves it unchanged; a changed failure moves last.
8831    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
8832        let position = self
8833            .query_failures
8834            .iter()
8835            .position(|entry| entry.id == owner.id);
8836        let Some(failure) = failure else {
8837            if let Some(index) = position {
8838                self.query_failures.remove(index);
8839            }
8840            return;
8841        };
8842        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
8843            .into_iter()
8844            .map(str::to_owned)
8845            .collect();
8846        let code = failure.code.unwrap_or("client.query_failed").to_owned();
8847        if position.is_some_and(|index| {
8848            let entry = &self.query_failures[index];
8849            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
8850        }) {
8851            return;
8852        }
8853        if let Some(index) = position {
8854            self.query_failures.remove(index);
8855        }
8856        self.query_failures.push(DiagnosticQueryFailure {
8857            id: owner.id.to_owned(),
8858            tables,
8859            code,
8860            sqlite_code: failure.sqlite_code,
8861            at_ms: self.clock_now_ms(),
8862        });
8863        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
8864            self.query_failures.remove(0);
8865        }
8866    }
8867
8868    // -- request building ---------------------------------------------------------
8869
8870    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
8871        #[cfg(feature = "bench-internals")]
8872        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
8873        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
8874        let mut frames = vec![Frame::ReqHeader {
8875            client_id: self.client_id.clone(),
8876            schema_version: self.schema.version,
8877            log_epoch: log_epoch.clone(),
8878        }];
8879        let mut pushed_ids = Vec::new();
8880        let mut ops_in_request = 0usize;
8881        let mut deferred_commits = 0usize;
8882        #[cfg(feature = "bench-internals")]
8883        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
8884        for (index, commit) in self
8885            .outbox
8886            .iter()
8887            .take(if log_epoch.is_some() {
8888                self.outbox.len()
8889            } else {
8890                0
8891            })
8892            .enumerate()
8893        {
8894            // §6.1 splitBatch: stop at the operation cap — commits apply in
8895            // order, so everything from the first non-fitting commit on is
8896            // deferred to the next round. A single over-cap commit still goes
8897            // alone (commits are atomic and cannot be split).
8898            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
8899                deferred_commits = self.outbox.len() - index;
8900                break;
8901            }
8902            ops_in_request += commit.ops.len();
8903            let operations = commit
8904                .ops
8905                .iter()
8906                .map(|op| {
8907                    let payload = op.values.as_ref().and_then(|values| {
8908                        let table = self.schema.table(&op.table)?;
8909                        // §0: outbox entries encode at send time with the
8910                        // current codec (validated at mutate()). §6.1: the
8911                        // values map's keys are the sparse presence set.
8912                        // §5.11: encrypted columns are encrypted here, with
8913                        // an absent key-id selector read from the stored row.
8914                        // `sync_inner` runs `drop_unencodable_outbox` first,
8915                        // so an encrypt failure cannot reach this encode; the
8916                        // author seam validated every other failure.
8917                        let fallback = self.stored_key_fallback(table, &op.row_id);
8918                        encode_sparse_row_json(
8919                            table,
8920                            &op.row_id,
8921                            values,
8922                            &self.encryption,
8923                            fallback.as_ref(),
8924                        )
8925                        .ok()
8926                    });
8927                    ssp2::model::Operation {
8928                        table: op.table.clone(),
8929                        row_id: op.row_id.clone(),
8930                        op: if op.upsert { Op::Upsert } else { Op::Delete },
8931                        base_version: op.base_version,
8932                        payload,
8933                    }
8934                })
8935                .collect();
8936            frames.push(Frame::PushCommit {
8937                client_commit_id: commit.client_commit_id.clone(),
8938                operations,
8939            });
8940            pushed_ids.push(commit.client_commit_id.clone());
8941        }
8942        #[cfg(feature = "bench-internals")]
8943        drop(outbox_phase);
8944        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
8945        // bare URL — capability negotiation, decided per transport.
8946        let accept = self.limits.accept.unwrap_or(if url_capable {
8947            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
8948        } else {
8949            DEFAULT_ACCEPT
8950        });
8951        frames.push(Frame::PullHeader {
8952            limit_commits: self.limits.limit_commits.unwrap_or(0),
8953            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
8954            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
8955            accept,
8956        });
8957        let mut fresh = Vec::new();
8958        for sub in &self.subs {
8959            if sub.state != SubState::Active {
8960                continue;
8961            }
8962            let mut scopes = sub.requested.clone();
8963            sort_scope_map(&mut scopes);
8964            frames.push(Frame::Subscription {
8965                id: sub.id.clone(),
8966                table: sub.table.clone(),
8967                scopes,
8968                params: sub.params.clone().map(RawJson),
8969                cursor: sub.cursor,
8970                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
8971            });
8972            fresh.push((
8973                sub.id.clone(),
8974                sub.cursor < 0 && sub.bootstrap_state.is_none(),
8975            ));
8976        }
8977        let message = Message {
8978            wire_version: WIRE_VERSION,
8979            msg_kind: MsgKind::Request,
8980            frames,
8981        };
8982        (
8983            message,
8984            RequestMeta {
8985                pushed_ids,
8986                fresh,
8987                accept,
8988                deferred_commits,
8989            },
8990        )
8991    }
8992
8993    // -- sync -------------------------------------------------------------------
8994
8995    /// Cloneable observation handle; no client lock is needed to read it.
8996    pub fn progress(&self) -> ProgressObserver {
8997        self.progress.clone()
8998    }
8999
9000    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9001        self.progress.start();
9002        let started_at_ms = self.clock_now_ms();
9003        let outcome = self.sync_inner(transport);
9004        self.progress.update(|p| match &outcome {
9005            SyncOutcome::Ok(report) if report.failed.is_empty() => {
9006                p.state = ProgressState::Complete
9007            }
9008            SyncOutcome::Ok(_) => {
9009                p.state = ProgressState::Failed;
9010                p.error_code = Some("sync.scope_revoked".into());
9011            }
9012            SyncOutcome::Failed { error_code, .. } => {
9013                p.state = ProgressState::Failed;
9014                p.error_code = Some(Self::diagnostic_code(error_code));
9015            }
9016            SyncOutcome::RealtimeUnavailable { .. } => {
9017                p.state = ProgressState::Failed;
9018                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
9019            }
9020        });
9021        let completed_at_ms = self.clock_now_ms();
9022        self.last_round = Some(match &outcome {
9023            SyncOutcome::Ok(report) => DiagnosticLastRound {
9024                status: "succeeded".to_owned(),
9025                started_at_ms,
9026                completed_at_ms,
9027                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9028                counters: Some(DiagnosticRoundCounters {
9029                    pushed: report.pushed,
9030                    applied: report.applied.len(),
9031                    rejected: report.rejected.len(),
9032                    retryable: report.retryable.len(),
9033                    conflicts: report.conflicts,
9034                    commits_applied: report.commits_applied,
9035                    segment_rows_applied: report.segment_rows_applied,
9036                    bootstrapping: report.bootstrapping.len(),
9037                    resets: report.resets.len(),
9038                    revoked: report.revoked.len(),
9039                    failed: report.failed.len(),
9040                    deferred_commits: report.deferred_commits,
9041                }),
9042                error_code: None,
9043            },
9044            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
9045                status: "failed".to_owned(),
9046                started_at_ms,
9047                completed_at_ms,
9048                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9049                counters: None,
9050                error_code: Some(Self::diagnostic_code(error_code)),
9051            },
9052            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
9053                status: "failed".to_owned(),
9054                started_at_ms,
9055                completed_at_ms,
9056                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9057                counters: None,
9058                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
9059            },
9060        });
9061        outcome
9062    }
9063
9064    fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9065        if self.stopped {
9066            // §1.6: the client stopped at the schema floor; syncing is inert
9067            // until an upgrade. The outbox is preserved for replay.
9068            return SyncOutcome::Ok(SyncReport {
9069                schema_floor: self.schema_floor.clone(),
9070                ..SyncReport::default()
9071            });
9072        }
9073        // §8.4: the coalesced sync-needed signal clears when a pull round
9074        // BEGINS, so a wake-up landing mid-round survives it.
9075        self.set_sync_needed(false, false);
9076        // §5.9.7 B4: upload pending blobs before pushing the referencing
9077        // rows, so the server-side existence check (§6.6) passes.
9078        if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
9079            if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
9080                if Self::retryable_failure_code(&code) {
9081                    self.schedule_background_retry();
9082                }
9083                return SyncOutcome::Failed {
9084                    error_code: code,
9085                    message,
9086                };
9087            }
9088        }
9089        if let Err(message) = self.drain_pending_evictions() {
9090            return SyncOutcome::Failed {
9091                error_code: "storage.failed".into(),
9092                message,
9093            };
9094        }
9095        // §5.11/§10.3: an unresolvable key id or an unknown key is a durable
9096        // per-commit rejection, never a reason to abort the round. Drop those
9097        // commits before the request is built.
9098        if let Err(message) = self.drop_unencodable_outbox() {
9099            return SyncOutcome::Failed {
9100                error_code: "storage.failed".into(),
9101                message,
9102            };
9103        }
9104        let (message, meta) = self.build_request(transport.supports_url_fetch());
9105        let request_bytes = {
9106            #[cfg(feature = "bench-internals")]
9107            let _phase = self.benchmark_phases.start(Phase::RequestEncode);
9108            encode_message(&message)
9109        };
9110        // §8.7: rounds ride the socket whenever it is connected (one
9111        // loop, no fallback pair); the transport seam stays bytes-in /
9112        // bytes-out either way. Registration-at-round-end is server-side.
9113        // §8.8: under `required`, a round entered while the socket is not
9114        // connected is refused as a typed state and NEVER falls back to the
9115        // request/response binding.
9116        let round = if self.realtime_state == RealtimeState::Connected {
9117            transport.realtime_sync(&request_bytes)
9118        } else if self.realtime_policy == RealtimePolicy::Required {
9119            let retry_delay_ms = self.schedule_background_retry();
9120            self.realtime_retry_delay_ms = Some(retry_delay_ms);
9121            return SyncOutcome::RealtimeUnavailable {
9122                state: self.realtime_state,
9123                reason_code: self.realtime_reason_code.clone(),
9124                retry_delay_ms,
9125            };
9126        } else {
9127            transport.sync(&request_bytes)
9128        };
9129        let realtime_round = self.realtime_state == RealtimeState::Connected;
9130        let response_bytes = match round {
9131            Ok(bytes) => bytes,
9132            Err(TransportError { code, message }) => {
9133                // §8.8: the socket could not carry the round. Record the loss
9134                // with the transport's code so a later round refuses under
9135                // `required` and reports the explicit state.
9136                if realtime_round {
9137                    self.set_realtime_state(RealtimeState::Lost, Some(&code));
9138                }
9139                // §7.3.5: a request-level lease code stops-and-surfaces —
9140                // record it in leaseState (no local-data purge, §7.3.4).
9141                self.record_lease_error(&code);
9142                if Self::retryable_failure_code(&code) {
9143                    let retry_delay_ms = self.schedule_background_retry();
9144                    if realtime_round {
9145                        self.realtime_retry_delay_ms = Some(retry_delay_ms);
9146                    }
9147                }
9148                return SyncOutcome::Failed {
9149                    error_code: code,
9150                    message,
9151                };
9152            }
9153        };
9154        #[cfg(feature = "bench-internals")]
9155        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9156        let response = match decode_message(&response_bytes) {
9157            Ok(message) => message,
9158            Err(error) => {
9159                // §1.2 rule 1 / §1.4 rule 5: truncated or malformed
9160                // responses abort without persisting anything.
9161                return SyncOutcome::Failed {
9162                    error_code: error.code.as_str().to_owned(),
9163                    message: error.detail,
9164                };
9165            }
9166        };
9167        #[cfg(feature = "bench-internals")]
9168        drop(decode_phase);
9169        if response.msg_kind != MsgKind::Response {
9170            return SyncOutcome::Failed {
9171                error_code: "sync.invalid_request".to_owned(),
9172                message: "expected a response message".to_owned(),
9173            };
9174        }
9175        let mut outcome = self.process_response(transport, response, &meta);
9176        if let SyncOutcome::Ok(report) = &mut outcome {
9177            report.deferred_commits = meta.deferred_commits;
9178        }
9179        match &outcome {
9180            SyncOutcome::Ok(_) => self.reset_background_retry(),
9181            SyncOutcome::Failed { error_code, .. } if Self::retryable_failure_code(error_code) => {
9182                self.schedule_background_retry();
9183            }
9184            SyncOutcome::Failed { .. } => {}
9185            SyncOutcome::RealtimeUnavailable { .. } => {}
9186        }
9187        if meta.deferred_commits > 0 {
9188            // §6.1 splitBatch: commits past the operation cap wait for the
9189            // next round — keep the host's sync signal raised until then.
9190            self.set_sync_needed(true, true);
9191        }
9192        // RFC 0005 D7: coverage completion, lease end, scope revocation and the
9193        // TTL are evaluated once per round, after the apply.
9194        self.previous_version_lifetime_check();
9195        outcome
9196    }
9197
9198    pub fn sync_until_idle(
9199        &mut self,
9200        transport: &mut dyn Transport,
9201        max_rounds: Option<u32>,
9202    ) -> SyncOutcome {
9203        let rounds = max_rounds.unwrap_or(20).max(1);
9204        let mut aggregate = SyncReport::default();
9205        let mut spent = 0;
9206        while spent < rounds {
9207            spent += 1;
9208            let before = self
9209                .subs
9210                .iter()
9211                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9212                .collect::<Vec<_>>();
9213            let outcome = self.sync(transport);
9214            match outcome {
9215                SyncOutcome::Failed {
9216                    error_code,
9217                    message,
9218                } => {
9219                    return SyncOutcome::Failed {
9220                        error_code,
9221                        message,
9222                    };
9223                }
9224                SyncOutcome::RealtimeUnavailable { .. } => return outcome,
9225                SyncOutcome::Ok(report) => {
9226                    if max_rounds.is_none()
9227                        && report.segment_rows_applied > 0
9228                        && !report.bootstrapping.is_empty()
9229                        && before
9230                            != self
9231                                .subs
9232                                .iter()
9233                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9234                                .collect::<Vec<_>>()
9235                    {
9236                        spent = 0;
9237                    }
9238                    aggregate.pushed += report.pushed;
9239                    aggregate.applied.extend(report.applied.iter().cloned());
9240                    aggregate.rejected.extend(report.rejected.iter().cloned());
9241                    aggregate.retryable.extend(report.retryable.iter().cloned());
9242                    aggregate.conflicts += report.conflicts;
9243                    aggregate.commits_applied += report.commits_applied;
9244                    aggregate.segment_rows_applied += report.segment_rows_applied;
9245                    aggregate.bootstrapping = report.bootstrapping.clone();
9246                    aggregate.resets.extend(report.resets.iter().cloned());
9247                    aggregate.revoked.extend(report.revoked.iter().cloned());
9248                    aggregate.failed.extend(report.failed.iter().cloned());
9249                    aggregate.deferred_commits = report.deferred_commits;
9250                    if report.schema_floor.is_some() {
9251                        aggregate.schema_floor = report.schema_floor.clone();
9252                    }
9253                    // §4.5: pull again whenever the response contained
9254                    // commits or segments; resets re-bootstrap; a pending
9255                    // resume token continues paging (§4.7); a raised
9256                    // sync-needed signal covers §6.1 splitBatch remainders
9257                    // (deferred outbox commits push on the next round).
9258                    let more = !report.bootstrapping.is_empty()
9259                        || report.commits_applied > 0
9260                        || report.segment_rows_applied > 0
9261                        || !report.resets.is_empty()
9262                        || self.sync_needed;
9263                    if !more {
9264                        return SyncOutcome::Ok(aggregate);
9265                    }
9266                }
9267            }
9268        }
9269        SyncOutcome::Failed {
9270            error_code: "sync.invalid_request".into(),
9271            message: "sync did not reach idle within the round budget".into(),
9272        }
9273    }
9274
9275    fn process_response(
9276        &mut self,
9277        transport: &mut dyn Transport,
9278        response: Message,
9279        meta: &RequestMeta,
9280    ) -> SyncOutcome {
9281        #[cfg(feature = "bench-internals")]
9282        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
9283        let mut report = SyncReport {
9284            pushed: meta.pushed_ids.len() as u32,
9285            ..SyncReport::default()
9286        };
9287        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
9288            HashMap::new();
9289        let pushed_ids = meta
9290            .pushed_ids
9291            .iter()
9292            .map(String::as_str)
9293            .collect::<HashSet<_>>();
9294        let mut last_final_push_result_id: Option<String> = None;
9295        for frame in &response.frames {
9296            match frame {
9297                Frame::PushResultDetails {
9298                    client_commit_id,
9299                    entries,
9300                } => {
9301                    let details = rejection_details_by_commit
9302                        .entry(client_commit_id.clone())
9303                        .or_default();
9304                    for entry in entries {
9305                        let parsed = match RejectionDetails::parse(&entry.details.0) {
9306                            Ok(value) => value,
9307                            Err(message) => {
9308                                return SyncOutcome::Failed {
9309                                    error_code: "sync.invalid_request".to_owned(),
9310                                    message,
9311                                };
9312                            }
9313                        };
9314                        details.insert(entry.op_index, parsed);
9315                    }
9316                }
9317                Frame::PushResult {
9318                    client_commit_id,
9319                    status,
9320                    results,
9321                    ..
9322                } if pushed_ids.contains(client_commit_id.as_str())
9323                    && Self::push_result_is_final(*status, results) =>
9324                {
9325                    last_final_push_result_id = Some(client_commit_id.clone());
9326                }
9327                _ => {}
9328            }
9329        }
9330        let mut frames = response.frames.into_iter().peekable();
9331        if response.wire_version < 2 {
9332            return SyncOutcome::Failed {
9333                error_code: "client.invalid_host_response".to_owned(),
9334                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
9335            };
9336        }
9337        match frames.next() {
9338            Some(Frame::RespHeader {
9339                required_schema_version,
9340                latest_schema_version,
9341                log_epoch,
9342                reset_required,
9343            }) => {
9344                let Some(log_epoch) = log_epoch else {
9345                    return SyncOutcome::Failed {
9346                        error_code: "client.invalid_host_response".to_owned(),
9347                        message: "response header omits logEpoch".to_owned(),
9348                    };
9349                };
9350                let Some(reset_required) = reset_required else {
9351                    return SyncOutcome::Failed {
9352                        error_code: "client.invalid_host_response".to_owned(),
9353                        message: "response header omits resetRequired".to_owned(),
9354                    };
9355                };
9356                if let Some(required) = required_schema_version {
9357                    // §1.6 schema-floor response: nothing else is processed;
9358                    // stop syncing and surface the upgrade requirement.
9359                    let floor = SchemaFloor {
9360                        required_schema_version: Some(required),
9361                        latest_schema_version,
9362                    };
9363                    self.set_schema_floor(Some(floor.clone()));
9364                    report.schema_floor = Some(floor);
9365                    return SyncOutcome::Ok(report);
9366                }
9367                if reset_required {
9368                    if frames.next().is_some() {
9369                        return SyncOutcome::Failed {
9370                            error_code: "client.invalid_host_response".to_owned(),
9371                            message: "log-epoch reset response contains body frames".to_owned(),
9372                        };
9373                    }
9374                    match self.run_log_epoch_reset(&log_epoch) {
9375                        Ok(resets) => {
9376                            report.resets = resets;
9377                            return SyncOutcome::Ok(report);
9378                        }
9379                        Err(message) => {
9380                            return SyncOutcome::Failed {
9381                                error_code: "sync.local_corrupt".to_owned(),
9382                                message,
9383                            };
9384                        }
9385                    }
9386                }
9387                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
9388                    return SyncOutcome::Failed {
9389                        error_code: "client.invalid_host_response".to_owned(),
9390                        message: "server changed logEpoch without requiring a reset".to_owned(),
9391                    };
9392                }
9393            }
9394            _ => {
9395                return SyncOutcome::Failed {
9396                    error_code: "sync.invalid_request".to_owned(),
9397                    message: "response does not start with RESP_HEADER".to_owned(),
9398                };
9399            }
9400        }
9401
9402        let mut failure: Option<(String, String)> = None;
9403        while let Some(frame) = frames.next() {
9404            match frame {
9405                frame @ Frame::PushResult { .. } => {
9406                    let successful = matches!(
9407                        &frame,
9408                        Frame::PushResult {
9409                            status: PushStatus::Applied | PushStatus::Cached,
9410                            ..
9411                        }
9412                    );
9413                    let mut results = vec![frame];
9414                    if successful {
9415                        while matches!(
9416                            frames.peek(),
9417                            Some(Frame::PushResult {
9418                                status: PushStatus::Applied | PushStatus::Cached,
9419                                ..
9420                            })
9421                        ) {
9422                            if let Some(next) = frames.next() {
9423                                results.push(next);
9424                            }
9425                        }
9426                    }
9427                    if self
9428                        .handle_push_results(
9429                            &results,
9430                            &pushed_ids,
9431                            &rejection_details_by_commit,
9432                            &mut report,
9433                            last_final_push_result_id.as_deref(),
9434                        )
9435                        .is_err()
9436                    {
9437                        failure = Some((
9438                            "client.outcome_persistence_failed".to_owned(),
9439                            "local commit outcome could not be persisted".to_owned(),
9440                        ));
9441                        break;
9442                    }
9443                }
9444                Frame::PushResultDetails { .. } => {}
9445                Frame::SubStart {
9446                    id,
9447                    status,
9448                    reason_code,
9449                    effective_scopes,
9450                    bootstrap: _,
9451                } => {
9452                    let mut body = Vec::new();
9453                    let mut sub_end: Option<(i64, Option<String>)> = None;
9454                    for inner in frames.by_ref() {
9455                        match inner {
9456                            Frame::SubEnd {
9457                                next_cursor,
9458                                bootstrap_state,
9459                            } => {
9460                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
9461                                break;
9462                            }
9463                            Frame::Unknown { .. } => {}
9464                            other => body.push(other),
9465                        }
9466                    }
9467                    if let Err(SectionError::Abort(code, message)) = self.process_section(
9468                        transport,
9469                        &id,
9470                        status,
9471                        &reason_code,
9472                        effective_scopes,
9473                        body,
9474                        sub_end,
9475                        meta,
9476                        &mut report,
9477                    ) {
9478                        failure = Some((code, message));
9479                        break;
9480                    }
9481                }
9482                Frame::Lease {
9483                    lease_id,
9484                    expires_at_ms,
9485                } => {
9486                    // §7.3.5: persist the opaque lease; a fresh lease clears
9487                    // any prior lease error (the outage/revocation is over).
9488                    self.set_lease_state(Some(LeaseState {
9489                        lease_id: Some(lease_id),
9490                        expires_at_ms: Some(expires_at_ms),
9491                        error_code: None,
9492                    }));
9493                }
9494                Frame::Error { code, message, .. } => {
9495                    // §1.6: the whole request failed; already-completed
9496                    // subscriptions keep their applied data and cursors.
9497                    failure = Some((code, message));
9498                    break;
9499                }
9500                Frame::Unknown { .. } => {}
9501                _ => {
9502                    failure = Some((
9503                        "sync.invalid_request".to_owned(),
9504                        "unexpected frame in response".to_owned(),
9505                    ));
9506                    break;
9507                }
9508            }
9509        }
9510
9511        // §7.1: reconcile the visible overlay once at the response boundary
9512        // after all outbox acknowledgements and server data applied so a
9513        // batch of PUSH_RESULT frames cannot trigger repeated full-table
9514        // rebuilds. This also covers a round that aborted mid-way.
9515        if self.overlay_dirty.get() {
9516            self.rebuild_overlay();
9517        }
9518        if let Some((error_code, message)) = failure {
9519            return SyncOutcome::Failed {
9520                error_code,
9521                message,
9522            };
9523        }
9524        // §4.8 E1: the push half may have drained commits that pinned rows of
9525        // a shrunk window unit — retry any deferred evictions now.
9526        if let Err(message) = self.drain_pending_evictions() {
9527            return SyncOutcome::Failed {
9528                error_code: "storage.failed".into(),
9529                message,
9530            };
9531        }
9532        self.ack_after_pull(transport);
9533        // §7.4.5: the reset is over once the first post-reset pull round
9534        // leaves no subscription mid-bootstrap — the tables are rebuilt.
9535        if self.upgrading && report.bootstrapping.is_empty() {
9536            self.set_upgrading(false);
9537        }
9538        SyncOutcome::Ok(report)
9539    }
9540
9541    // -- push results (§6.3, §7.2) ------------------------------------------------
9542
9543    fn handle_push_results(
9544        &mut self,
9545        frames: &[Frame],
9546        pushed_ids: &HashSet<&str>,
9547        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
9548        report: &mut SyncReport,
9549        last_final_push_result_id: Option<&str>,
9550    ) -> Result<(), String> {
9551        self.begin_observation("syncular_push_results")?;
9552        let conflict_count = self.conflicts.len();
9553        let rejection_count = self.rejections.len();
9554        let applied_count = report.applied.len();
9555        let rejected_count = report.rejected.len();
9556        let retryable_count = report.retryable.len();
9557        let was_dirty = self.overlay_dirty.get();
9558        let mut removed = Vec::new();
9559        let mut batch = ChangeAccumulator::default();
9560        let persisted = (|| {
9561            for frame in frames {
9562                let Frame::PushResult {
9563                    client_commit_id,
9564                    status,
9565                    results,
9566                    ..
9567                } = frame
9568                else {
9569                    return Err("unexpected acknowledgement frame".to_owned());
9570                };
9571                if !pushed_ids.contains(client_commit_id.as_str()) {
9572                    continue;
9573                }
9574                let Some(index) = self
9575                    .outbox
9576                    .iter()
9577                    .position(|commit| commit.client_commit_id == *client_commit_id)
9578                else {
9579                    continue;
9580                };
9581                let status = *status;
9582                let rejection_details = rejection_details_by_commit.get(client_commit_id);
9583                let operations = self.outbox[index].ops.clone();
9584                match status {
9585                    PushStatus::Applied | PushStatus::Cached => {
9586                        // §7.2: a lost ack replays as `cached` — proceed as if the
9587                        // ack had arrived.
9588                        let journal_results = results
9589                            .iter()
9590                            .map(|result| {
9591                                let op_index = match result {
9592                                    OpResult::Applied { op_index }
9593                                    | OpResult::Conflict { op_index, .. }
9594                                    | OpResult::Error { op_index, .. } => *op_index,
9595                                };
9596                                CommitOperationOutcome::Applied { op_index }
9597                            })
9598                            .collect::<Vec<_>>();
9599                        let outcome_status = if status == PushStatus::Applied {
9600                            CommitOutcomeStatus::Applied
9601                        } else {
9602                            CommitOutcomeStatus::Cached
9603                        };
9604                        let persisted = self
9605                            .persist_commit_outcome(
9606                                client_commit_id,
9607                                outcome_status,
9608                                &journal_results,
9609                                None,
9610                            )
9611                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9612                        persisted?;
9613                        self.overlay_dirty.set(true);
9614                        batch.status = true;
9615                        batch.outcomes = true;
9616                    }
9617                    PushStatus::Rejected => {
9618                        if results.iter().any(|result| {
9619                            matches!(
9620                                result,
9621                                OpResult::Error {
9622                                    code,
9623                                    retryable: true,
9624                                    ..
9625                                } if code == "sync.idempotency_cache_miss"
9626                            )
9627                        }) {
9628                            // §6.3/§7.2: a serving failure, not an outcome — keep the
9629                            // exact commit queued for an identical retry.
9630                            report.retryable.push(client_commit_id.to_owned());
9631                            continue;
9632                        }
9633
9634                        let mut journal_results = Vec::with_capacity(results.len());
9635                        let mut conflicts = Vec::new();
9636                        let mut rejections = Vec::new();
9637                        for result in results {
9638                            match result {
9639                                OpResult::Applied { op_index } => {
9640                                    journal_results.push(CommitOperationOutcome::Applied {
9641                                        op_index: *op_index,
9642                                    });
9643                                }
9644                                OpResult::Conflict {
9645                                    op_index,
9646                                    code,
9647                                    message,
9648                                    server_version,
9649                                    server_row,
9650                                    conflict_columns: conflict_columns_bitmap,
9651                                } => {
9652                                    let operation = operations
9653                                        .get(*op_index as usize)
9654                                        .map(CommitOperation::from);
9655                                    let (table, row_id) = operation
9656                                        .as_ref()
9657                                        .map(|op| (op.table.clone(), op.row_id.clone()))
9658                                        .unwrap_or_default();
9659                                    let server_row_json = self
9660                                        .schema
9661                                        .table(&table)
9662                                        .and_then(|t| {
9663                                            decode_row_bytes(t, server_row, &self.encryption)
9664                                                .ok()
9665                                                .map(|row| (t, row))
9666                                        })
9667                                        .map(|(t, row)| {
9668                                            let mut map = Map::new();
9669                                            for (i, column) in t.columns.iter().enumerate() {
9670                                                map.insert(
9671                                                    column.name.clone(),
9672                                                    column_value_to_json(
9673                                                        row.get(i).unwrap_or(&None),
9674                                                    ),
9675                                                );
9676                                            }
9677                                            map
9678                                        })
9679                                        .unwrap_or_default();
9680                                    // §6.3: decode the presence-layout bitmap
9681                                    // into the marked column names.
9682                                    let conflict_columns = self
9683                                        .schema
9684                                        .table(&table)
9685                                        .map(|t| {
9686                                            t.columns
9687                                                .iter()
9688                                                .enumerate()
9689                                                .filter(|(index, _)| {
9690                                                    conflict_columns_bitmap
9691                                                        .get(index / 8)
9692                                                        .is_some_and(|byte| {
9693                                                            byte & (1 << (index % 8)) != 0
9694                                                        })
9695                                                })
9696                                                .map(|(_, column)| column.name.clone())
9697                                                .collect::<Vec<_>>()
9698                                        })
9699                                        .unwrap_or_default();
9700                                    let conflict = ConflictRecord {
9701                                        client_commit_id: client_commit_id.to_owned(),
9702                                        op_index: *op_index,
9703                                        table,
9704                                        row_id,
9705                                        code: code.clone(),
9706                                        message: message.clone(),
9707                                        server_version: *server_version,
9708                                        server_row: server_row_json,
9709                                        conflict_columns,
9710                                        operation,
9711                                    };
9712                                    journal_results.push(CommitOperationOutcome::Conflict {
9713                                        conflict: conflict.clone(),
9714                                    });
9715                                    conflicts.push(conflict);
9716                                }
9717                                OpResult::Error {
9718                                    op_index,
9719                                    code,
9720                                    message,
9721                                    retryable,
9722                                } => {
9723                                    let rejection = RejectionRecord {
9724                                        client_commit_id: client_commit_id.to_owned(),
9725                                        op_index: *op_index,
9726                                        code: code.clone(),
9727                                        message: message.clone(),
9728                                        retryable: *retryable,
9729                                        details: rejection_details
9730                                            .and_then(|details| details.get(op_index))
9731                                            .cloned(),
9732                                        operation: operations
9733                                            .get(*op_index as usize)
9734                                            .map(CommitOperation::from),
9735                                    };
9736                                    journal_results.push(CommitOperationOutcome::Error {
9737                                        rejection: rejection.clone(),
9738                                    });
9739                                    rejections.push(rejection);
9740                                }
9741                            }
9742                        }
9743                        let outcome_status = if conflicts.is_empty() {
9744                            CommitOutcomeStatus::Rejected
9745                        } else {
9746                            CommitOutcomeStatus::Conflict
9747                        };
9748                        let persisted = self
9749                            .persist_commit_outcome(
9750                                client_commit_id,
9751                                outcome_status,
9752                                &journal_results,
9753                                Some(&operations),
9754                            )
9755                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9756                        persisted?;
9757                        batch.conflicts = !conflicts.is_empty();
9758                        batch.rejections = !rejections.is_empty();
9759                        batch.status = true;
9760                        batch.outcomes = true;
9761                        self.conflicts.extend(conflicts);
9762                        self.rejections.extend(rejections);
9763                        self.overlay_dirty.set(true);
9764                    }
9765                }
9766                removed.push((index, self.outbox.remove(index)));
9767                if batch.status {
9768                    for operation in &operations {
9769                        if !self.record_row_scopes(
9770                            &mut batch,
9771                            &operation.table,
9772                            &operation.row_id,
9773                            false,
9774                        ) {
9775                            batch.table(&operation.table);
9776                        }
9777                    }
9778                }
9779                match status {
9780                    PushStatus::Applied | PushStatus::Cached => {
9781                        report.applied.push(client_commit_id.to_owned())
9782                    }
9783                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
9784                }
9785            }
9786            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)) {
9787                self.prune_commit_outcomes()?;
9788            }
9789            self.finish_observation("syncular_push_results", batch)
9790        })();
9791        if let Err(error) = persisted {
9792            self.rollback_observation("syncular_push_results");
9793            for (index, commit) in removed.into_iter().rev() {
9794                self.outbox.insert(index, commit);
9795            }
9796            self.conflicts.truncate(conflict_count);
9797            self.rejections.truncate(rejection_count);
9798            self.overlay_dirty.set(was_dirty);
9799            report.applied.truncate(applied_count);
9800            report.rejected.truncate(rejected_count);
9801            report.retryable.truncate(retryable_count);
9802            return Err(error);
9803        }
9804        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
9805        Ok(())
9806    }
9807
9808    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
9809        status != PushStatus::Rejected
9810            || !results.iter().any(|result| {
9811                matches!(
9812                    result,
9813                    OpResult::Error {
9814                        code,
9815                        retryable: true,
9816                        ..
9817                    } if code == "sync.idempotency_cache_miss"
9818                )
9819            })
9820    }
9821
9822    // -- subscription sections ------------------------------------------------------
9823
9824    #[allow(clippy::too_many_arguments)]
9825    fn process_section(
9826        &mut self,
9827        transport: &mut dyn Transport,
9828        id: &str,
9829        status: SubStatus,
9830        reason_code: &str,
9831        effective_scopes: Vec<(String, Vec<String>)>,
9832        body: Vec<Frame>,
9833        sub_end: Option<(i64, Option<String>)>,
9834        meta: &RequestMeta,
9835        report: &mut SyncReport,
9836    ) -> Result<(), SectionError> {
9837        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9838            return Ok(()); // unknown echo: ignore
9839        };
9840        if status != SubStatus::Active && sub_end.is_none() {
9841            return Err(SectionError::Abort(
9842                "sync.invalid_request".into(),
9843                "subscription section without SUB_END".into(),
9844            ));
9845        }
9846        match status {
9847            SubStatus::Revoked => {
9848                self.begin_observation("syncular_revocation")
9849                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9850                let previous = self.subs[sub_index].clone();
9851                let was_dirty = self.overlay_dirty.get();
9852                let previous_outbox = self.outbox.clone();
9853                let rejection_count = self.rejections.len();
9854                let revoked_count = report.revoked.len();
9855                let failed_count = report.failed.len();
9856                let result =
9857                    (|| {
9858                        let mut batch = ChangeAccumulator::default();
9859                        let registered = self.window_unit_by_sub_id(id);
9860                        // §3.3: stop pulling, purge exactly the last effective grant.
9861                        let (table, effective) = {
9862                            let sub = &self.subs[sub_index];
9863                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9864                        };
9865                        let purged = self.purge_scope_rows(&table, &effective);
9866                        match purged {
9867                            Ok(()) => {
9868                                self.record_scope_map(&mut batch, &table, &effective);
9869                                let sub = &mut self.subs[sub_index];
9870                                sub.state = SubState::Revoked;
9871                                sub.reason_code = Some(if reason_code.is_empty() {
9872                                    "sync.scope_revoked".to_owned()
9873                                } else {
9874                                    reason_code.to_owned()
9875                                });
9876                                report.revoked.push(id.to_owned());
9877                                let doomed_effective = effective;
9878                                let sub_table = table;
9879                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
9880                                    |message| SectionError::Abort("storage.failed".into(), message),
9881                                )?;
9882                                let dropped = self
9883                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
9884                                    .map_err(|message| {
9885                                        SectionError::Abort("storage.failed".to_owned(), message)
9886                                    })?;
9887                                if dropped {
9888                                    batch.status = true;
9889                                    batch.rejections = true;
9890                                    batch.outcomes = true;
9891                                }
9892                                // §5.9.7 B2: revocation deletes now-unauthorized blob
9893                                // bodies (evicted ≠ revoked).
9894                                self.delete_unreferenced_cached_blobs();
9895                            }
9896                            Err(()) => {
9897                                // §3.3 fail closed: no local mapping — never clear by
9898                                // approximation; fatal configuration error.
9899                                let sub = &mut self.subs[sub_index];
9900                                sub.state = SubState::Failed;
9901                                sub.reason_code = Some("sync.scope_revoked".to_owned());
9902                                report.failed.push(id.to_owned());
9903                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
9904                                    |message| SectionError::Abort("storage.failed".into(), message),
9905                                )?;
9906                            }
9907                        }
9908                        if let Some((base_key, unit)) = registered {
9909                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
9910                        }
9911                        self.rebuild_overlay_if_dirty();
9912                        self.finish_observation("syncular_revocation", batch)
9913                            .map_err(|message| {
9914                                SectionError::Abort("storage.failed".to_owned(), message)
9915                            })?;
9916                        Ok(())
9917                    })();
9918                if result.is_err() {
9919                    self.rollback_observation("syncular_revocation");
9920                    self.subs[sub_index] = previous;
9921                    self.overlay_dirty.set(was_dirty);
9922                    self.outbox = previous_outbox;
9923                    self.rejections.truncate(rejection_count);
9924                    report.revoked.truncate(revoked_count);
9925                    report.failed.truncate(failed_count);
9926                }
9927                result
9928            }
9929            SubStatus::Reset => {
9930                self.begin_observation("syncular_reset")
9931                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9932                let previous = self.subs[sub_index].clone();
9933                let was_dirty = self.overlay_dirty.get();
9934                let reset_count = report.resets.len();
9935                let result = (|| {
9936                    let mut batch = ChangeAccumulator::default();
9937                    let registered = self.window_unit_by_sub_id(id);
9938                    // §4.6: discard cursor + bootstrap state, keep local rows —
9939                    // reset is a staleness signal, not a purge signal.
9940                    let sub = &mut self.subs[sub_index];
9941                    sub.cursor = -1;
9942                    sub.bootstrap_state = None;
9943                    report.resets.push(id.to_owned());
9944                    self.persist_sub(&self.subs[sub_index].clone())
9945                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9946                    if let Some((base_key, unit)) = registered {
9947                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
9948                    }
9949                    self.finish_observation("syncular_reset", batch)
9950                        .map_err(|message| {
9951                            SectionError::Abort("storage.failed".to_owned(), message)
9952                        })?;
9953                    Ok(())
9954                })();
9955                if result.is_err() {
9956                    self.rollback_observation("syncular_reset");
9957                    self.subs[sub_index] = previous;
9958                    self.overlay_dirty.set(was_dirty);
9959                    report.resets.truncate(reset_count);
9960                }
9961                result
9962            }
9963            SubStatus::Active => {
9964                let fresh = meta
9965                    .fresh
9966                    .iter()
9967                    .find(|(fid, _)| fid == id)
9968                    .map(|(_, fresh)| *fresh)
9969                    .unwrap_or(false);
9970                let previous = self.subs[sub_index].clone();
9971                self.subs[sub_index].effective = Some(effective_scopes);
9972                let outcome = self
9973                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
9974                    .and_then(|()| {
9975                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
9976                            SectionError::Abort(
9977                                "sync.invalid_request".into(),
9978                                "subscription section without SUB_END".into(),
9979                            )
9980                        })?;
9981                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
9982                            .map_err(|message| {
9983                                SectionError::Abort("storage.failed".into(), message)
9984                            })
9985                    });
9986                match outcome {
9987                    Ok(()) => {
9988                        if self.subs[sub_index].bootstrap_state.is_some() {
9989                            report.bootstrapping.push(id.to_owned());
9990                        }
9991                        Ok(())
9992                    }
9993                    Err(SectionError::FailClosed) => {
9994                        self.subs[sub_index] = previous.clone();
9995                        self.begin_observation("syncular_section_failure")
9996                            .map_err(|message| {
9997                                SectionError::Abort("storage.failed".into(), message)
9998                            })?;
9999                        let persisted = (|| {
10000                            let mut batch = ChangeAccumulator::default();
10001                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
10002                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
10003                            }
10004                            self.subs[sub_index].state = SubState::Failed;
10005                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
10006                            self.persist_sub(&self.subs[sub_index])?;
10007                            self.finish_observation("syncular_section_failure", batch)
10008                        })();
10009                        if let Err(message) = persisted {
10010                            self.rollback_observation("syncular_section_failure");
10011                            self.subs[sub_index] = previous;
10012                            return Err(SectionError::Abort("storage.failed".into(), message));
10013                        }
10014                        report.failed.push(id.to_owned());
10015                        Ok(())
10016                    }
10017                    Err(error) => {
10018                        // Frame/block transactions already completed. Preserve their
10019                        // rows and revisions, but leave this subscription cursor unchanged.
10020                        self.subs[sub_index] = previous;
10021                        Err(error)
10022                    }
10023                }
10024            }
10025        }
10026    }
10027
10028    fn apply_sub_end(
10029        &mut self,
10030        sub_index: usize,
10031        cursor: i64,
10032        bootstrap_state: Option<String>,
10033    ) -> Result<(), String> {
10034        #[cfg(feature = "bench-internals")]
10035        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
10036        let previous = self.subs[sub_index].clone();
10037        self.begin_observation("syncular_sub_end")?;
10038        let persisted = (|| {
10039            let mut batch = ChangeAccumulator::default();
10040            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
10041                && cursor >= 0
10042                && bootstrap_state.is_none();
10043            if completed {
10044                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
10045                    batch.window(&base_key, &previous.table, &unit);
10046                }
10047            }
10048            let sub = &mut self.subs[sub_index];
10049            sub.cursor = cursor;
10050            sub.bootstrap_state = bootstrap_state;
10051            sub.synced_once = true;
10052            self.persist_sub(&self.subs[sub_index])?;
10053            self.finish_observation("syncular_sub_end", batch)
10054        })();
10055        if persisted.is_err() {
10056            self.rollback_observation("syncular_sub_end");
10057            self.subs[sub_index] = previous;
10058        }
10059        persisted
10060    }
10061
10062    fn apply_section_body(
10063        &mut self,
10064        transport: &mut dyn Transport,
10065        sub_index: usize,
10066        body: Vec<Frame>,
10067        fresh: bool,
10068        meta: &RequestMeta,
10069        report: &mut SyncReport,
10070    ) -> Result<(), SectionError> {
10071        let mut saw_segment = false;
10072        for frame in body {
10073            match frame {
10074                Frame::Commit {
10075                    tables, changes, ..
10076                } => {
10077                    self.apply_commit_frame(&tables, &changes)
10078                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
10079                    report.commits_applied += 1;
10080                }
10081                Frame::SegmentInline { payload } => {
10082                    let segment = decode_rows_segment(&payload)
10083                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
10084                    self.progress.update(|p| {
10085                        p.phase = ProgressPhase::Import;
10086                        p.subscription_id = Some(self.subs[sub_index].id.clone());
10087                        p.table = Some(self.subs[sub_index].table.clone());
10088                        p.segment_id = None;
10089                        p.bytes_received = payload.len() as u64;
10090                        p.bytes_total = Some(payload.len() as u64);
10091                        p.rows_processed = 0;
10092                        p.rows_total =
10093                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
10094                    });
10095                    let first = !saw_segment;
10096                    saw_segment = true;
10097                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10098                    report.segment_rows_applied += applied;
10099                }
10100                Frame::SegmentRef {
10101                    segment_id,
10102                    byte_length,
10103                    media_type,
10104                    table,
10105                    row_count,
10106                    as_of_commit_seq,
10107                    scope_digest,
10108                    row_cursor,
10109                    next_row_cursor,
10110                    url,
10111                    url_expires_at_ms,
10112                    ..
10113                } => {
10114                    // §4.2: reject a descriptor whose mediaType was not
10115                    // advertised — never skip or guess.
10116                    let advertised = match media_type {
10117                        MediaType::Rows => {
10118                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
10119                                || meta.accept & ACCEPT_INLINE_ROWS != 0
10120                        }
10121                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
10122                    };
10123                    if !advertised {
10124                        return Err(SectionError::Abort(
10125                            "sync.invalid_request".to_owned(),
10126                            format!(
10127                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
10128                                media_type.name()
10129                            ),
10130                        ));
10131                    }
10132                    self.progress.update(|p| {
10133                        p.phase = ProgressPhase::Download;
10134                        p.subscription_id = Some(self.subs[sub_index].id.clone());
10135                        p.table = Some(table.clone());
10136                        p.segment_id = Some(segment_id.clone());
10137                        p.bytes_received = 0;
10138                        p.bytes_total = u64::try_from(byte_length).ok();
10139                        p.rows_processed = 0;
10140                        p.rows_total = u64::try_from(row_count).ok();
10141                    });
10142                    let observer = self.progress.clone();
10143                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
10144                    let bytes = if let Some(url) = url {
10145                        // §5.4: a url-carrying descriptor MUST be fetched
10146                        // from that URL; failure invalidates the whole
10147                        // descriptor (no fall-through to §5.5 — re-pull
10148                        // recovers, §1.4 rule 5).
10149                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
10150                            return Err(SectionError::Abort(
10151                                "sync.invalid_request".to_owned(),
10152                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
10153                                    .to_owned(),
10154                            ));
10155                        }
10156                        // §5.4: MUST NOT start a fetch at/past expiry.
10157                        if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10158                            return Err(SectionError::Abort(
10159                                "sync.segment_expired".to_owned(),
10160                                format!(
10161                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
10162                                ),
10163                            ));
10164                        }
10165                        transport
10166                            .fetch_url(&url, &mut on_progress)
10167                            .map_err(|e| SectionError::Abort(e.code, e.message))?
10168                    } else {
10169                        let requested_scopes_json =
10170                            canonical_scope_json(&self.subs[sub_index].requested);
10171                        transport
10172                            .download_segment(
10173                                &SegmentRequest {
10174                                    segment_id: segment_id.clone(),
10175                                    table,
10176                                    requested_scopes_json,
10177                                },
10178                                &mut on_progress,
10179                            )
10180                            .map_err(|e| SectionError::Abort(e.code, e.message))?
10181                    };
10182                    // §5.1: verify the content address before applying.
10183                    on_progress(bytes.len() as u64);
10184                    let digest = Sha256::digest(&bytes);
10185                    let expected = segment_id
10186                        .strip_prefix("sha256:")
10187                        .unwrap_or(segment_id.as_str());
10188                    if bytes_to_hex(&digest) != expected {
10189                        return Err(SectionError::Abort(
10190                            "sync.invalid_request".to_owned(),
10191                            "segment bytes do not match the content address (§5.1)".to_owned(),
10192                        ));
10193                    }
10194                    self.progress.update(|p| p.phase = ProgressPhase::Import);
10195                    if media_type == MediaType::Sqlite {
10196                        // §5.3: images are whole-table — a paged sqlite
10197                        // descriptor is invalid.
10198                        if row_cursor.is_some() || next_row_cursor.is_some() {
10199                            return Err(SectionError::Abort(
10200                                "sync.invalid_request".to_owned(),
10201                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
10202                                    .to_owned(),
10203                            ));
10204                        }
10205                        let first = !saw_segment;
10206                        saw_segment = true;
10207                        let applied = self.apply_sqlite_segment(
10208                            sub_index,
10209                            &bytes,
10210                            fresh && first,
10211                            row_count,
10212                            as_of_commit_seq,
10213                            &scope_digest,
10214                        )?;
10215                        report.segment_rows_applied += applied;
10216                    } else {
10217                        let segment = decode_rows_segment(&bytes).map_err(|e| {
10218                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
10219                        })?;
10220                        let first = row_cursor.is_none();
10221                        saw_segment = true;
10222                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10223                        report.segment_rows_applied += applied;
10224                    }
10225                }
10226                Frame::Unknown { .. } => {}
10227                Frame::Error { code, message, .. } => {
10228                    return Err(SectionError::Abort(code, message))
10229                }
10230                _ => {
10231                    return Err(SectionError::Abort(
10232                        "sync.invalid_request".to_owned(),
10233                        "unexpected frame inside a subscription section".to_owned(),
10234                    ));
10235                }
10236            }
10237        }
10238        Ok(())
10239    }
10240
10241    // Both pull and realtime frames use the same durable observation boundary.
10242    fn apply_commit_frame(
10243        &mut self,
10244        tables: &[String],
10245        changes: &[ssp2::model::Change],
10246    ) -> Result<(), (String, String)> {
10247        #[cfg(feature = "bench-internals")]
10248        let _phase = self.benchmark_phases.start(Phase::CommitApply);
10249        let was_dirty = self.overlay_dirty.get();
10250        self.begin_observation("syncular_commit")
10251            .map_err(|message| ("storage.failed".into(), message))?;
10252        let applied = (|| {
10253            let mut batch = ChangeAccumulator::default();
10254            self.record_commit_changes(&mut batch, tables, changes);
10255            self.apply_commit_changes(tables, changes)?;
10256            self.rebuild_overlay_if_dirty();
10257            self.finish_observation("syncular_commit", batch)
10258                .map_err(|message| ("storage.failed".into(), message))
10259        })();
10260        if applied.is_err() {
10261            self.rollback_observation("syncular_commit");
10262            self.overlay_dirty.set(was_dirty);
10263        }
10264        applied
10265    }
10266
10267    fn apply_commit_changes(
10268        &mut self,
10269        tables: &[String],
10270        changes: &[ssp2::model::Change],
10271    ) -> Result<(), (String, String)> {
10272        // A clean overlay can reconcile changed primary keys independently
10273        // when their tables have no secondary unique constraints. Such a
10274        // constraint can change whether another row's pending write succeeds,
10275        // so those frames still require the complete FIFO replay below.
10276        let mirror_visible = !self.overlay_dirty.get()
10277            && (self.outbox.is_empty()
10278                || changes.iter().all(|change| {
10279                    tables
10280                        .get(change.table_index as usize)
10281                        .and_then(|name| self.schema.table(name))
10282                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
10283                }));
10284        for change in changes {
10285            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
10286                (
10287                    "sync.invalid_request".to_owned(),
10288                    "change tableIndex out of range".to_owned(),
10289                )
10290            })?;
10291            let table = self.schema.table(table_name).ok_or_else(|| {
10292                (
10293                    "sync.schema_mismatch".to_owned(),
10294                    format!("change targets unknown table {table_name:?}"),
10295                )
10296            })?;
10297            match change.op {
10298                Op::Upsert => {
10299                    let payload = change.row.as_ref().ok_or_else(|| {
10300                        (
10301                            "sync.invalid_request".to_owned(),
10302                            "upsert change without row payload".to_owned(),
10303                        )
10304                    })?;
10305                    // §5.11: decrypt encrypted columns on apply.
10306                    let row = {
10307                        #[cfg(feature = "bench-internals")]
10308                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
10309                        decode_row_bytes(table, payload, &self.encryption)
10310                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
10311                    };
10312                    let version = change.row_version.unwrap_or(0);
10313                    let table_name = table.name.clone();
10314                    self.write_base_row(&table_name, &row, version)
10315                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10316                    if mirror_visible {
10317                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
10318                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10319                    }
10320                }
10321                Op::Delete => {
10322                    self.delete_base_row(table_name, &change.row_id)
10323                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10324                    if mirror_visible {
10325                        let sql = format!(
10326                            "DELETE FROM {} WHERE {}",
10327                            visible_table(table_name),
10328                            row_id_predicate(table)
10329                        );
10330                        self.conn
10331                            .prepare_cached(&sql)
10332                            .and_then(|mut statement| {
10333                                statement.execute(rusqlite::params![change.row_id])
10334                            })
10335                            .map_err(|error| {
10336                                ("sync.invalid_request".to_owned(), error.to_string())
10337                            })?;
10338                    }
10339                }
10340            }
10341        }
10342        if mirror_visible {
10343            if !self.outbox.is_empty() {
10344                let changed_rows: HashSet<(&str, &str)> = changes
10345                    .iter()
10346                    .map(|change| {
10347                        (
10348                            tables[change.table_index as usize].as_str(),
10349                            change.row_id.as_str(),
10350                        )
10351                    })
10352                    .collect();
10353                self.apply_outbox_ops(
10354                    self.outbox
10355                        .iter()
10356                        .flat_map(|commit| &commit.ops)
10357                        .filter(|op| {
10358                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
10359                        }),
10360                );
10361            }
10362            self.overlay_dirty.set(false);
10363        }
10364        Ok(())
10365    }
10366
10367    /// §5.6 segment application: validate against the generated schema,
10368    /// clear the grant on a fresh bootstrap's first page (fail closed
10369    /// without a mapping), then replace-or-upsert each row with its
10370    /// segment-carried server version (§5.2).
10371    fn apply_segment(
10372        &mut self,
10373        sub_index: usize,
10374        segment: &RowsSegment,
10375        first_fresh_page: bool,
10376    ) -> Result<u32, SectionError> {
10377        let (sub_table, effective) = {
10378            let sub = &self.subs[sub_index];
10379            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10380        };
10381        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10382            SectionError::Abort(
10383                "sync.schema_mismatch".to_owned(),
10384                format!("subscription table {sub_table:?} is not in the client schema"),
10385            )
10386        })?;
10387        // §5.2: the column table validates against the generated schema —
10388        // order, names, types, nullability; mismatch is fatal. §5.11: the
10389        // server sends the WIRE types (bytes for an encrypted column), so
10390        // validate against wire_columns.
10391        let matches = segment.table == table.name
10392            && segment.schema_version == self.schema.version
10393            && segment.columns.len() == table.wire_columns.len()
10394            && segment
10395                .columns
10396                .iter()
10397                .zip(table.wire_columns.iter())
10398                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
10399        if !matches {
10400            return Err(SectionError::Abort(
10401                "sync.schema_mismatch".to_owned(),
10402                "segment column table does not match the generated schema (§5.2)".to_owned(),
10403            ));
10404        }
10405        let mut applied = 0u32;
10406        let empty = Vec::new();
10407        let blocks = if segment.blocks.is_empty() {
10408            std::slice::from_ref(&empty)
10409        } else {
10410            &segment.blocks
10411        };
10412        for (index, block) in blocks.iter().enumerate() {
10413            let clear = first_fresh_page && index == 0;
10414            if !clear && block.is_empty() {
10415                continue;
10416            }
10417            let was_dirty = self.overlay_dirty.get();
10418            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10419                && self
10420                    .outbox
10421                    .iter()
10422                    .flat_map(|commit| &commit.ops)
10423                    .any(|op| op.table == table.name);
10424            let mirror_visible = !reconcile_pending;
10425            self.begin_observation("syncular_segment_block")
10426                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10427            let outcome = (|| {
10428                self.rebuild_overlay_if_dirty();
10429                let mut changed_keys = Vec::new();
10430                let mut batch = ChangeAccumulator::default();
10431                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
10432                    batch.table(&table.name);
10433                }
10434                if clear {
10435                    self.purge_scope_rows(&table.name, &effective)
10436                        .map_err(|()| SectionError::FailClosed)?;
10437                }
10438                for (row_index, row) in block.iter().enumerate() {
10439                    let decrypted;
10440                    let values = if table.has_encrypted_columns() {
10441                        let mut values = row.values.clone();
10442                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
10443                            .map_err(|message| {
10444                            SectionError::Abort("client.decrypt_failed".into(), message)
10445                        })?;
10446                        decrypted = values;
10447                        &decrypted
10448                    } else {
10449                        &row.values
10450                    };
10451                    self.write_base_row(&table.name, values, row.server_version)
10452                        .map_err(|message| {
10453                            SectionError::Abort("sync.invalid_request".into(), message)
10454                        })?;
10455                    if reconcile_pending {
10456                        changed_keys.push(
10457                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
10458                                |message| SectionError::Abort("storage.failed".into(), message),
10459                            )?,
10460                        );
10461                    }
10462                    if mirror_visible {
10463                        self.write_row(
10464                            &visible_table(&table.name),
10465                            &table.name,
10466                            values,
10467                            row.server_version,
10468                        )
10469                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10470                    }
10471                    let processed = u64::from(applied) + row_index as u64 + 1;
10472                    if processed.is_multiple_of(1024) {
10473                        self.progress.update(|p| p.rows_processed = processed);
10474                    }
10475                }
10476                if reconcile_pending {
10477                    self.reconcile_imported_rows(&table, &mut changed_keys)
10478                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10479                }
10480                self.apply_outbox_ops(
10481                    self.outbox
10482                        .iter()
10483                        .flat_map(|commit| &commit.ops)
10484                        .filter(|op| op.table == table.name),
10485                );
10486                self.overlay_dirty.set(false);
10487                self.finish_observation("syncular_segment_block", batch)
10488                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
10489            })();
10490            if let Err(error) = outcome {
10491                self.rollback_observation("syncular_segment_block");
10492                self.overlay_dirty.set(was_dirty);
10493                return Err(error);
10494            }
10495            applied += block.len() as u32;
10496            self.progress
10497                .update(|p| p.rows_processed = u64::from(applied));
10498            std::thread::yield_now();
10499        }
10500        self.progress
10501            .update(|p| p.rows_processed = u64::from(applied));
10502        Ok(applied)
10503    }
10504
10505    /// §5.3 sqlite-image application: validate the in-file metadata
10506    /// against the descriptor, validate column names/order against the
10507    /// generated schema, run the §5.6 first-page clear when fresh, then
10508    /// replace-or-upsert every image row with its `_syncular_version`.
10509    /// Mechanics: the image lands in a temp file read through a second
10510    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
10511    /// Each image chunk commits its writes and revision before readers continue.
10512    fn apply_sqlite_segment(
10513        &mut self,
10514        sub_index: usize,
10515        bytes: &[u8],
10516        first_fresh_page: bool,
10517        row_count: i64,
10518        as_of_commit_seq: i64,
10519        scope_digest: &str,
10520    ) -> Result<u32, SectionError> {
10521        let invalid = |detail: &str| {
10522            SectionError::Abort(
10523                "sync.invalid_request".to_owned(),
10524                format!("sqlite segment rejected: {detail} (§5.3)"),
10525            )
10526        };
10527        let (sub_table, effective) = {
10528            let sub = &self.subs[sub_index];
10529            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10530        };
10531        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10532            SectionError::Abort(
10533                "sync.schema_mismatch".to_owned(),
10534                format!("subscription table {sub_table:?} is not in the client schema"),
10535            )
10536        })?;
10537
10538        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
10539        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
10540        let img = match rusqlite::Connection::open_with_flags(
10541            &path,
10542            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
10543        ) {
10544            Ok(conn) => conn,
10545            Err(_) => {
10546                let _ = std::fs::remove_file(&path);
10547                return Err(invalid("bytes do not open as a SQLite database"));
10548            }
10549        };
10550        let outcome = self.apply_sqlite_image(
10551            &img,
10552            &table,
10553            first_fresh_page,
10554            &effective,
10555            row_count,
10556            as_of_commit_seq,
10557            scope_digest,
10558        );
10559        drop(img);
10560        let _ = std::fs::remove_file(&path);
10561        outcome
10562    }
10563
10564    #[allow(clippy::too_many_arguments)]
10565    fn apply_sqlite_image(
10566        &mut self,
10567        img: &rusqlite::Connection,
10568        table: &crate::schema::TableSchema,
10569        first_fresh_page: bool,
10570        effective: &[(String, Vec<String>)],
10571        row_count: i64,
10572        as_of_commit_seq: i64,
10573        scope_digest: &str,
10574    ) -> Result<u32, SectionError> {
10575        let invalid = |detail: String| {
10576            SectionError::Abort(
10577                "sync.invalid_request".to_owned(),
10578                format!("sqlite segment rejected: {detail} (§5.3)"),
10579            )
10580        };
10581
10582        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
10583        //    one row).
10584        type MetaRow = (i64, String, i64, i64, String, i64, i64);
10585        let meta: MetaRow = img
10586            .query_row(
10587                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
10588                        \"scopeDigest\", \"rowCount\",
10589                        (SELECT count(*) FROM _syncular_segment)
10590                 FROM _syncular_segment",
10591                [],
10592                |row| {
10593                    Ok((
10594                        row.get(0)?,
10595                        row.get(1)?,
10596                        row.get(2)?,
10597                        row.get(3)?,
10598                        row.get(4)?,
10599                        row.get(5)?,
10600                        row.get(6)?,
10601                    ))
10602                },
10603            )
10604            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
10605        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
10606        if meta_count != 1 {
10607            return Err(invalid(format!(
10608                "_syncular_segment must contain exactly one row, found {meta_count}"
10609            )));
10610        }
10611        if format != 1 {
10612            return Err(invalid(format!("format {format}")));
10613        }
10614        if meta_table != table.name {
10615            return Err(invalid(format!("image table {meta_table:?}")));
10616        }
10617        if schema_version != i64::from(self.schema.version) {
10618            return Err(invalid(format!("schemaVersion {schema_version}")));
10619        }
10620        if pin != as_of_commit_seq {
10621            return Err(invalid(format!("asOfCommitSeq {pin}")));
10622        }
10623        if digest != scope_digest {
10624            return Err(invalid("scopeDigest mismatch".to_owned()));
10625        }
10626        if meta_rows != row_count {
10627            return Err(invalid(format!("rowCount {meta_rows}")));
10628        }
10629
10630        // 2. Column names and order vs the generated schema (§5.3 rule 3).
10631        let mut names: Vec<String> = Vec::new();
10632        let mut primary_keys: Vec<String> = Vec::new();
10633        {
10634            let mut stmt = img
10635                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
10636                .map_err(|_| invalid("image data table missing".to_owned()))?;
10637            let mut rows = stmt
10638                .query([])
10639                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
10640            while let Some(row) = rows
10641                .next()
10642                .map_err(|_| invalid("image data table unreadable".to_owned()))?
10643            {
10644                if row.get::<_, i64>(5).map_err(|e| invalid(e.to_string()))? > 0 {
10645                    primary_keys.push(row.get(1).map_err(|e| invalid(e.to_string()))?);
10646                }
10647                names.push(
10648                    row.get::<_, String>(1)
10649                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
10650                );
10651            }
10652        }
10653        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
10654        expected.push("_syncular_version");
10655        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
10656            return Err(SectionError::Abort(
10657                "sync.schema_mismatch".to_owned(),
10658                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
10659            ));
10660        }
10661
10662        if primary_keys != vec![table.primary_key.clone()] {
10663            return Err(invalid(
10664                "image primary key does not match generated schema".into(),
10665            ));
10666        }
10667        let actual_count: i64 = img
10668            .query_row(
10669                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
10670                [],
10671                |row| row.get(0),
10672            )
10673            .map_err(|e| invalid(e.to_string()))?;
10674        if actual_count != row_count {
10675            return Err(invalid("image row count does not match descriptor".into()));
10676        }
10677        let insert = self.insert_row_sql(&base_table(&table.name), table);
10678        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
10679        let column_list = names
10680            .iter()
10681            .map(|n| quote_ident(n))
10682            .collect::<Vec<_>>()
10683            .join(", ");
10684        let mut stmt = img
10685            .prepare(&format!(
10686                "SELECT {column_list} FROM {} ORDER BY {}",
10687                quote_ident(&table.name),
10688                quote_ident(&table.primary_key)
10689            ))
10690            .map_err(|e| invalid(e.to_string()))?;
10691        let mut rows = stmt.query([]).map_err(|e| invalid(e.to_string()))?;
10692        let mut applied = 0u32;
10693        loop {
10694            let clear = applied == 0 && first_fresh_page;
10695            let was_dirty = self.overlay_dirty.get();
10696            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10697                && self
10698                    .outbox
10699                    .iter()
10700                    .flat_map(|commit| &commit.ops)
10701                    .any(|op| op.table == table.name);
10702            let mirror_visible = !reconcile_pending;
10703            self.begin_observation("syncular_image_chunk")
10704                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10705            let result = (|| {
10706                self.rebuild_overlay_if_dirty();
10707                let mut changed_keys = Vec::new();
10708                let mut batch = ChangeAccumulator::default();
10709                if clear {
10710                    if self.scoped_rows_exist(&table.name, effective) {
10711                        batch.table(&table.name);
10712                    }
10713                    self.purge_scope_rows(&table.name, effective)
10714                        .map_err(|()| SectionError::FailClosed)?;
10715                }
10716                let mut processed = 0u32;
10717                {
10718                    let mut ins = self
10719                        .conn
10720                        .prepare_cached(&insert)
10721                        .map_err(|e| invalid(e.to_string()))?;
10722                    let mut mirror = if mirror_visible {
10723                        Some(
10724                            self.conn
10725                                .prepare_cached(&visible_insert)
10726                                .map_err(|e| invalid(e.to_string()))?,
10727                        )
10728                    } else {
10729                        None
10730                    };
10731                    for _ in 0..1024 {
10732                        let Some(row) = rows.next().map_err(|e| invalid(e.to_string()))? else {
10733                            break;
10734                        };
10735                        for (i, column) in table.columns.iter().enumerate() {
10736                            let cell = row.get_ref(i).map_err(|e| invalid(e.to_string()))?;
10737                            let param = image_cell_param(column, cell).map_err(&invalid)?;
10738                            ins.raw_bind_parameter(i + 1, &param)
10739                                .map_err(|e| invalid(e.to_string()))?;
10740                            if let Some(mirror) = &mut mirror {
10741                                mirror
10742                                    .raw_bind_parameter(i + 1, &param)
10743                                    .map_err(|e| invalid(e.to_string()))?;
10744                            }
10745                        }
10746                        let version_index = table.columns.len();
10747                        let version: i64 =
10748                            row.get(version_index).map_err(|e| invalid(e.to_string()))?;
10749                        if version < 1 {
10750                            return Err(invalid("image row version must be positive".into()));
10751                        }
10752                        ins.raw_bind_parameter(version_index + 1, version)
10753                            .map_err(|e| invalid(e.to_string()))?;
10754                        ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
10755                        if let Some(mirror) = &mut mirror {
10756                            mirror
10757                                .raw_bind_parameter(version_index + 1, version)
10758                                .map_err(|e| invalid(e.to_string()))?;
10759                            mirror.raw_execute().map_err(|e| invalid(e.to_string()))?;
10760                        }
10761                        if reconcile_pending {
10762                            changed_keys.push(
10763                                row.get::<_, SqlValue>(table.pk_index)
10764                                    .map_err(|error| invalid(error.to_string()))?,
10765                            );
10766                        }
10767                        processed += 1;
10768                    }
10769                }
10770                if processed > 0 {
10771                    batch.table(&table.name);
10772                    self.overlay_dirty.set(true);
10773                }
10774                if reconcile_pending {
10775                    self.reconcile_imported_rows(table, &mut changed_keys)
10776                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10777                }
10778                self.apply_outbox_ops(
10779                    self.outbox
10780                        .iter()
10781                        .flat_map(|commit| &commit.ops)
10782                        .filter(|op| op.table == table.name),
10783                );
10784                self.overlay_dirty.set(false);
10785                self.finish_observation("syncular_image_chunk", batch)
10786                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10787                Ok(processed)
10788            })();
10789            let processed = match result {
10790                Ok(processed) => processed,
10791                Err(error) => {
10792                    self.rollback_observation("syncular_image_chunk");
10793                    self.overlay_dirty.set(was_dirty);
10794                    return Err(error);
10795                }
10796            };
10797            applied += processed;
10798            self.progress
10799                .update(|p| p.rows_processed = u64::from(applied));
10800            std::thread::yield_now();
10801            if i64::from(applied) == actual_count {
10802                break;
10803            }
10804        }
10805        Ok(applied)
10806    }
10807
10808    /// Restore only imported and pending row identities before FIFO replay. All pending
10809    /// identities participate: a server row can free a unique value for a different edit.
10810    /// The caller owns the chunk transaction and publishes its revision after replay.
10811    fn reconcile_imported_rows(
10812        &self,
10813        table: &TableSchema,
10814        keys: &mut Vec<SqlValue>,
10815    ) -> Result<(), String> {
10816        let visible = visible_table(&table.name);
10817        let base = base_table(&table.name);
10818        let pk = quote_ident(&table.primary_key);
10819        let predicate = row_id_predicate(table);
10820        let mut lookup = self.conn.prepare_cached(&format!(
10821            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10822        )).map_err(|error| error.to_string())?;
10823        let mut deleted = HashSet::new();
10824        for op in self
10825            .outbox
10826            .iter()
10827            .flat_map(|commit| &commit.ops)
10828            .filter(|op| op.table == table.name)
10829        {
10830            if op.upsert {
10831                if let Some(values) = &op.values {
10832                    let key = json_to_column_value(
10833                        &table.columns[table.pk_index],
10834                        values.get(&table.primary_key),
10835                    )?;
10836                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
10837                }
10838            } else if deleted.insert(&op.row_id) {
10839                keys.extend(
10840                    lookup
10841                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10842                        .map_err(|error| error.to_string())?
10843                        .collect::<Result<Vec<_>, _>>()
10844                        .map_err(|error| error.to_string())?,
10845                );
10846            }
10847        }
10848        // Delete the complete affected set before restoring any base value, so
10849        // old optimistic unique values cannot collide with the new server rows.
10850        for chunk in keys.chunks(400) {
10851            let holes = vec!["?"; chunk.len()].join(",");
10852            self.conn
10853                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10854                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10855                .map_err(|error| error.to_string())?;
10856        }
10857        let updates = table
10858            .columns
10859            .iter()
10860            .map(|column| column.name.as_str())
10861            .chain(std::iter::once("_syncular_version"))
10862            .filter(|name| *name != table.primary_key)
10863            .map(|name| {
10864                let name = quote_ident(name);
10865                format!("{name}=excluded.{name}")
10866            })
10867            .collect::<Vec<_>>()
10868            .join(",");
10869        for chunk in keys.chunks(400) {
10870            let holes = vec!["?"; chunk.len()].join(",");
10871            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10872                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10873                .map_err(|error| error.to_string())?;
10874        }
10875        Ok(())
10876    }
10877
10878    // -- application-authorized local purge ---------------------------------------
10879
10880    fn compile_local_data_purge(
10881        &self,
10882        input: &LocalDataPurgeInput,
10883    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10884        let invalid = |message: String| format!("sync.invalid_request: {message}");
10885        if input.purge_id.is_empty()
10886            || input.purge_id.len() > 128
10887            || !is_local_operation_code_like(&input.purge_id)
10888        {
10889            return Err(invalid(
10890                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10891            ));
10892        }
10893        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10894            return Err(invalid(format!(
10895                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10896            )));
10897        }
10898
10899        let mut deduplicated: BTreeMap<
10900            String,
10901            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10902        > = BTreeMap::new();
10903        for target in &input.targets {
10904            let table = self.schema.table(&target.table).ok_or_else(|| {
10905                invalid(format!(
10906                    "local purge names unknown table {:?}",
10907                    target.table
10908                ))
10909            })?;
10910            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10911                return Err(invalid(format!(
10912                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10913                    target.table
10914                )));
10915            }
10916            let mut selectors = Vec::with_capacity(target.selectors.len());
10917            let mut canonical_selectors = BTreeMap::new();
10918            for (column_name, raw_values) in &target.selectors {
10919                let Some((column_index, column)) = table
10920                    .columns
10921                    .iter()
10922                    .enumerate()
10923                    .find(|(_, column)| column.name == *column_name)
10924                else {
10925                    return Err(invalid(format!(
10926                        "local purge target {:?} names unknown column {:?}",
10927                        target.table, column_name
10928                    )));
10929                };
10930                let encrypted = table
10931                    .encrypted_columns
10932                    .iter()
10933                    .any(|candidate| candidate.index == column_index);
10934                if column.ty != ColumnType::String || encrypted {
10935                    return Err(invalid(format!(
10936                        "local purge selector {:?}.{:?} must be a plaintext string column",
10937                        target.table, column_name
10938                    )));
10939                }
10940                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10941                    return Err(invalid(format!(
10942                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10943                        target.table, column_name
10944                    )));
10945                }
10946                let mut values = raw_values.clone();
10947                values.sort();
10948                values.dedup();
10949                if values.iter().any(|value| {
10950                    value.is_empty()
10951                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10952                        || !is_local_operation_code_like(value)
10953                }) {
10954                    return Err(invalid(format!(
10955                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10956                    )));
10957                }
10958                selectors.push((column_name.clone(), values.clone()));
10959                canonical_selectors.insert(column_name.clone(), values);
10960            }
10961            let canonical = LocalDataPurgeTarget {
10962                table: target.table.clone(),
10963                selectors: canonical_selectors,
10964            };
10965            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10966            deduplicated.insert(
10967                key,
10968                (
10969                    CompiledLocalDataPurgeTarget {
10970                        table: target.table.clone(),
10971                        selectors,
10972                    },
10973                    canonical,
10974                ),
10975            );
10976        }
10977        let targets = deduplicated
10978            .values()
10979            .map(|(compiled, _)| compiled.clone())
10980            .collect::<Vec<_>>();
10981        let canonical = deduplicated
10982            .values()
10983            .map(|(_, target)| target.clone())
10984            .collect::<Vec<_>>();
10985        let canonical_plan =
10986            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10987        Ok((targets, canonical_plan))
10988    }
10989
10990    fn local_purge_base_row_ids(
10991        &self,
10992        targets: &[CompiledLocalDataPurgeTarget],
10993    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
10994        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10995        for target in targets {
10996            let table = self
10997                .schema
10998                .table(&target.table)
10999                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
11000            let mut clauses = Vec::with_capacity(target.selectors.len());
11001            let mut params = Vec::new();
11002            for (column, values) in &target.selectors {
11003                clauses.push(format!(
11004                    "{} IN ({})",
11005                    quote_ident(column),
11006                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
11007                ));
11008                params.extend(values.iter().cloned().map(SqlValue::Text));
11009            }
11010            let sql = format!(
11011                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
11012                quote_ident(&table.primary_key),
11013                base_table(&target.table),
11014                clauses.join(" AND ")
11015            );
11016            let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
11017            let rows = statement
11018                .query_map(rusqlite::params_from_iter(params), |row| {
11019                    row.get::<_, String>(0)
11020                })
11021                .map_err(|error| error.to_string())?;
11022            let ids = by_table.entry(target.table.clone()).or_default();
11023            for row in rows {
11024                ids.insert(row.map_err(|error| error.to_string())?);
11025            }
11026        }
11027        Ok(by_table)
11028    }
11029
11030    fn local_purge_values_match(
11031        target: &CompiledLocalDataPurgeTarget,
11032        values: &Map<String, Value>,
11033    ) -> bool {
11034        target.selectors.iter().all(|(column, allowed)| {
11035            values
11036                .get(column)
11037                .and_then(Value::as_str)
11038                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
11039        })
11040    }
11041
11042    /// Apply one host-authorized local security purge. The host MUST gate the
11043    /// corresponding subscriptions first; this primitive owns local SQLite
11044    /// cleanup and never grants/revokes server authority by itself.
11045    pub fn purge_local_data(
11046        &mut self,
11047        input: &LocalDataPurgeInput,
11048    ) -> Result<LocalDataPurgeResult, String> {
11049        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
11050        let meta_key = format!("localPurge:{}", input.purge_id);
11051        let applied_plan = self
11052            .conn
11053            .query_row(
11054                "SELECT value FROM _syncular_meta WHERE key = ?1",
11055                rusqlite::params![meta_key],
11056                |row| row.get::<_, String>(0),
11057            )
11058            .optional()
11059            .map_err(|error| error.to_string())?;
11060        if let Some(applied_plan) = applied_plan {
11061            if applied_plan != canonical_plan {
11062                return Err(format!(
11063                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
11064                    input.purge_id
11065                ));
11066            }
11067            return Ok(LocalDataPurgeResult {
11068                already_applied: true,
11069                purged_rows: 0,
11070                dropped_commits: 0,
11071            });
11072        }
11073
11074        // RFC 0005 D7: the container file cannot be a purge target (its name is
11075        // not in the running schema), so purge drops it as a fixed step, before
11076        // any mirror row is touched and unconditionally.
11077        let _ = self.drop_previous_version();
11078
11079        let prior_outbox = self.outbox.clone();
11080        let prior_rejection_count = self.rejections.len();
11081        let prior_overlay_dirty = self.overlay_dirty.get();
11082        self.begin_observation("syncular_local_purge")?;
11083        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
11084            let row_ids = self.local_purge_base_row_ids(&targets)?;
11085            let doomed = self
11086                .outbox
11087                .iter()
11088                .filter(|commit| {
11089                    commit.ops.iter().any(|operation| {
11090                        let matching_targets = targets
11091                            .iter()
11092                            .filter(|target| target.table == operation.table)
11093                            .collect::<Vec<_>>();
11094                        if matching_targets.is_empty() {
11095                            return false;
11096                        }
11097                        if row_ids
11098                            .get(&operation.table)
11099                            .is_some_and(|ids| ids.contains(&operation.row_id))
11100                        {
11101                            return true;
11102                        }
11103                        operation.values.as_ref().is_some_and(|values| {
11104                            matching_targets
11105                                .iter()
11106                                .any(|target| Self::local_purge_values_match(target, values))
11107                        })
11108                    })
11109                })
11110                .cloned()
11111                .collect::<Vec<_>>();
11112            let doomed_ids = doomed
11113                .iter()
11114                .map(|commit| commit.client_commit_id.clone())
11115                .collect::<BTreeSet<_>>();
11116            let mut batch = ChangeAccumulator::default();
11117            let mut rejections = Vec::new();
11118            for commit in &doomed {
11119                for operation in &commit.ops {
11120                    batch.table(&operation.table);
11121                }
11122                let results = commit
11123                    .ops
11124                    .iter()
11125                    .enumerate()
11126                    .map(|(op_index, operation)| {
11127                        let rejection = RejectionRecord {
11128                            client_commit_id: commit.client_commit_id.clone(),
11129                            op_index: op_index as i32,
11130                            code: "client.local_data_purged".to_owned(),
11131                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
11132                            retryable: false,
11133                            details: None,
11134                            operation: Some(CommitOperation::from(operation)),
11135                        };
11136                        rejections.push(rejection.clone());
11137                        CommitOperationOutcome::Error { rejection }
11138                    })
11139                    .collect::<Vec<_>>();
11140                self.persist_commit_outcome(
11141                    &commit.client_commit_id,
11142                    CommitOutcomeStatus::Rejected,
11143                    &results,
11144                    Some(&commit.ops),
11145                )?;
11146                self.delete_outbox_persisted(&commit.client_commit_id)?;
11147            }
11148            if !doomed.is_empty() {
11149                self.outbox
11150                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
11151                self.rejections.extend(rejections);
11152                self.prune_commit_outcomes()?;
11153                self.overlay_dirty.set(true);
11154                batch.status = true;
11155                batch.rejections = true;
11156                batch.outcomes = true;
11157            }
11158
11159            let mut purged_rows = 0usize;
11160            for (table, ids) in &row_ids {
11161                if ids.is_empty() {
11162                    continue;
11163                }
11164                batch.table(table);
11165                for row_id in ids {
11166                    self.delete_base_row(table, row_id)?;
11167                    purged_rows += 1;
11168                }
11169            }
11170            self.rebuild_overlay_if_dirty();
11171            self.delete_unreferenced_cached_blobs();
11172            self.conn
11173                .execute(
11174                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11175                    rusqlite::params![meta_key, canonical_plan],
11176                )
11177                .map_err(|error| error.to_string())?;
11178            Ok((
11179                batch,
11180                LocalDataPurgeResult {
11181                    already_applied: false,
11182                    purged_rows,
11183                    dropped_commits: doomed.len(),
11184                },
11185            ))
11186        })();
11187        let (batch, result) = match applied {
11188            Ok(value) => value,
11189            Err(error) => {
11190                self.rollback_observation("syncular_local_purge");
11191                self.outbox = prior_outbox;
11192                self.rejections.truncate(prior_rejection_count);
11193                self.overlay_dirty.set(prior_overlay_dirty);
11194                return Err(error);
11195            }
11196        };
11197        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
11198            self.rollback_observation("syncular_local_purge");
11199            self.outbox = prior_outbox;
11200            self.rejections.truncate(prior_rejection_count);
11201            self.overlay_dirty.set(prior_overlay_dirty);
11202            return Err(error);
11203        }
11204        Ok(result)
11205    }
11206
11207    /// Application-authorized recovery of the replicated projection. Unlike a
11208    /// local security purge, this retains the entire outbox, device identity,
11209    /// lease, outcomes, subscription registrations, and protected bookkeeping.
11210    /// The projection reset, subscription rewind, optimistic replay, and
11211    /// idempotency marker share one savepoint for interruption safety.
11212    pub fn rebootstrap_local_data(
11213        &mut self,
11214        input: &LocalDataRebootstrapInput,
11215    ) -> Result<LocalDataRebootstrapResult, String> {
11216        if input.rebootstrap_id.is_empty()
11217            || input.rebootstrap_id.len() > 128
11218            || !is_local_operation_code_like(&input.rebootstrap_id)
11219        {
11220            return Err(
11221                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
11222                    .to_owned(),
11223            );
11224        }
11225        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
11226        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
11227            let (retained_commits, reset_subscriptions) =
11228                decode_local_rebootstrap_receipt(&persisted)?;
11229            return Ok(LocalDataRebootstrapResult {
11230                already_applied: true,
11231                retained_commits,
11232                reset_subscriptions,
11233            });
11234        }
11235        if self.stopped || self.schema_floor.is_some() {
11236            return Err(
11237                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
11238                    .to_owned(),
11239            );
11240        }
11241
11242        let retained_commits = self.outbox.len();
11243        let reset_subscriptions = self.subs.len();
11244        let prior_subs = self.subs.clone();
11245        let prior_upgrading = self.upgrading;
11246        let prior_stopped = self.stopped;
11247        let prior_schema_floor = self.schema_floor.clone();
11248        let prior_overlay_dirty = self.overlay_dirty.get();
11249        let prior_sync_needed = self.sync_needed;
11250        let prior_sync_intents = self.sync_intent_queue.clone();
11251        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
11252
11253        self.begin_observation("syncular_local_rebootstrap")?;
11254        let mut batch = ChangeAccumulator::default();
11255        let applied = (|| -> Result<(), String> {
11256            self.run_schema_reset_observed(&mut batch, false, false)?;
11257            self.conn
11258                .execute(
11259                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11260                    rusqlite::params![meta_key, receipt],
11261                )
11262                .map_err(|error| error.to_string())?;
11263            self.sync_needed = true;
11264            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11265            batch.status = true;
11266            Ok(())
11267        })();
11268        if let Err(error) = applied {
11269            self.rollback_observation("syncular_local_rebootstrap");
11270            self.subs = prior_subs;
11271            self.upgrading = prior_upgrading;
11272            self.stopped = prior_stopped;
11273            self.schema_floor = prior_schema_floor;
11274            self.overlay_dirty.set(prior_overlay_dirty);
11275            self.sync_needed = prior_sync_needed;
11276            self.sync_intent_queue = prior_sync_intents;
11277            return Err(error);
11278        }
11279        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
11280            self.rollback_observation("syncular_local_rebootstrap");
11281            self.subs = prior_subs;
11282            self.upgrading = prior_upgrading;
11283            self.stopped = prior_stopped;
11284            self.schema_floor = prior_schema_floor;
11285            self.overlay_dirty.set(prior_overlay_dirty);
11286            self.sync_needed = prior_sync_needed;
11287            self.sync_intent_queue = prior_sync_intents;
11288            return Err(error);
11289        }
11290
11291        Ok(LocalDataRebootstrapResult {
11292            already_applied: false,
11293            retained_commits,
11294            reset_subscriptions,
11295        })
11296    }
11297
11298    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
11299
11300    /// Delete base rows matching the effective scopes; `Err(())` = no local
11301    /// scope-column mapping for a key (the fail-closed case).
11302    fn purge_scope_rows(
11303        &mut self,
11304        table_name: &str,
11305        effective: &[(String, Vec<String>)],
11306    ) -> Result<(), ()> {
11307        if effective.is_empty() {
11308            return Ok(());
11309        }
11310        let table = self.schema.table(table_name).ok_or(())?.clone();
11311        let mut clauses = Vec::new();
11312        let mut params: Vec<SqlValue> = Vec::new();
11313        for (variable, values) in effective {
11314            let column = table.scope_column(variable).ok_or(())?;
11315            let placeholders: Vec<String> = values
11316                .iter()
11317                .map(|v| {
11318                    params.push(SqlValue::Text(v.clone()));
11319                    "?".to_owned()
11320                })
11321                .collect();
11322            clauses.push(format!(
11323                "{} IN ({})",
11324                quote_ident(column),
11325                placeholders.join(", ")
11326            ));
11327        }
11328        let sql = format!(
11329            "DELETE FROM {} WHERE {}",
11330            base_table(table_name),
11331            clauses.join(" AND ")
11332        );
11333        self.overlay_dirty.set(true);
11334        self.conn
11335            .execute(&sql, rusqlite::params_from_iter(&params))
11336            .map_err(|_| ())?;
11337        self.conn
11338            .execute(
11339                &format!(
11340                    "DELETE FROM {} WHERE {}",
11341                    visible_table(table_name),
11342                    clauses.join(" AND ")
11343                ),
11344                rusqlite::params_from_iter(&params),
11345            )
11346            .map_err(|_| ())?;
11347        Ok(())
11348    }
11349
11350    /// §3.3: drop pending commits whose upserts provably land in the
11351    /// revoked effective scopes — whole-commit, never per-operation.
11352    fn drop_doomed_outbox(
11353        &mut self,
11354        table_name: &str,
11355        effective: &[(String, Vec<String>)],
11356    ) -> Result<bool, String> {
11357        if effective.is_empty() {
11358            return Ok(false);
11359        }
11360        let Some(table) = self.schema.table(table_name).cloned() else {
11361            return Ok(false);
11362        };
11363        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
11364        for (variable, values) in effective {
11365            match table.scope_column(variable) {
11366                Some(column) => mappings.push((column, values)),
11367                None => return Ok(false), // not provable without a mapping
11368            }
11369        }
11370        let doomed: Vec<OutboxCommit> = self
11371            .outbox
11372            .iter()
11373            .filter(|commit| {
11374                commit.ops.iter().any(|op| {
11375                    op.upsert
11376                        && op.table == table_name
11377                        && op.values.as_ref().is_some_and(|values| {
11378                            mappings
11379                                .iter()
11380                                .all(|(column, allowed)| match values.get(*column) {
11381                                    Some(Value::String(s)) => allowed.contains(s),
11382                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
11383                                    _ => false,
11384                                })
11385                        })
11386                })
11387            })
11388            .cloned()
11389            .collect();
11390        if doomed.is_empty() {
11391            return Ok(false);
11392        }
11393        let mut rejections = Vec::new();
11394        for commit in &doomed {
11395            let results = commit
11396                .ops
11397                .iter()
11398                .enumerate()
11399                .map(|(op_index, operation)| {
11400                    let rejection = RejectionRecord {
11401                        client_commit_id: commit.client_commit_id.clone(),
11402                        op_index: op_index as i32,
11403                        code: "sync.scope_revoked".to_owned(),
11404                        message: "the commit was dropped because its effective scope was revoked"
11405                            .to_owned(),
11406                        retryable: false,
11407                        details: None,
11408                        operation: Some(CommitOperation::from(operation)),
11409                    };
11410                    rejections.push(rejection.clone());
11411                    CommitOperationOutcome::Error { rejection }
11412                })
11413                .collect::<Vec<_>>();
11414            self.persist_commit_outcome(
11415                &commit.client_commit_id,
11416                CommitOutcomeStatus::Rejected,
11417                &results,
11418                Some(&commit.ops),
11419            )?;
11420            self.delete_outbox_persisted(&commit.client_commit_id)?;
11421        }
11422        self.prune_commit_outcomes()?;
11423        let doomed_ids = doomed
11424            .iter()
11425            .map(|commit| commit.client_commit_id.as_str())
11426            .collect::<BTreeSet<_>>();
11427        self.outbox
11428            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
11429        self.rejections.extend(rejections);
11430        self.overlay_dirty.set(true);
11431        Ok(true)
11432    }
11433
11434    // -- blobs (§5.9) ----------------------------------------------------------------
11435
11436    /// §5.9.7: hash bytes into the content address, cache them, queue the
11437    /// upload (flushed before the next push, B4). Returns the canonical
11438    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
11439    /// column value.
11440    pub fn upload_blob(
11441        &mut self,
11442        bytes: &[u8],
11443        media_type: Option<String>,
11444        name: Option<String>,
11445    ) -> Result<Value, String> {
11446        let blob_id = blob_id_for(bytes);
11447        let now = self.clock_now_ms();
11448        let tx = self.conn.transaction().map_err(|e| e.to_string())?;
11449        tx.execute(
11450            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
11451             ON CONFLICT(blob_id) DO NOTHING",
11452            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
11453        )
11454        .map_err(|e| e.to_string())?;
11455        tx.execute(
11456            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
11457            rusqlite::params![blob_id, media_type, now],
11458        )
11459        .map_err(|e| e.to_string())?;
11460        tx.commit().map_err(|e| e.to_string())?;
11461        self.enforce_blob_cache_cap();
11462        let mut obj = Map::new();
11463        obj.insert("blobId".to_owned(), Value::from(blob_id));
11464        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
11465        if let Some(mt) = media_type {
11466            obj.insert("mediaType".to_owned(), Value::from(mt));
11467        }
11468        if let Some(n) = name {
11469            obj.insert("name".to_owned(), Value::from(n));
11470        }
11471        Ok(Value::Object(obj))
11472    }
11473
11474    /// §5.9.7: resolve an owned blob body without driver encoding. A
11475    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
11476    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
11477    pub fn fetch_blob_bytes(
11478        &mut self,
11479        transport: &mut dyn Transport,
11480        blob_id_or_ref: &str,
11481    ) -> Result<FetchedBlob, (String, String)> {
11482        let simple = |m: String| ("client.failed".to_owned(), m);
11483        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
11484            blob_id_or_ref.to_owned()
11485        } else {
11486            let value: Value = serde_json::from_str(blob_id_or_ref)
11487                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
11488            value
11489                .get("blobId")
11490                .and_then(Value::as_str)
11491                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
11492                .to_owned()
11493        };
11494        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
11495            return Ok(cached);
11496        }
11497        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
11498        // blob.not_found) verbatim so the harness can assert on it. The
11499        // authorized endpoint serves bytes inline OR (always-issue, presign
11500        // configured) a signed url the client fetches directly — no host auth,
11501        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
11502        #[cfg(feature = "bench-internals")]
11503        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
11504        let bytes = match transport
11505            .blob_download(&blob_id)
11506            .map_err(|e| (e.code, e.message))?
11507        {
11508            BlobDownload::Bytes(bytes) => bytes,
11509            BlobDownload::Url {
11510                url,
11511                url_expires_at_ms,
11512            } => {
11513                // §5.9.5: MUST NOT start a fetch at/past expiry.
11514                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
11515                    return Err((
11516                        "sync.segment_expired".to_owned(),
11517                        format!(
11518                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
11519                        ),
11520                    ));
11521                }
11522                transport
11523                    .fetch_blob_url(&url)
11524                    .map_err(|e| (e.code, e.message))?
11525            }
11526        };
11527        #[cfg(feature = "bench-internals")]
11528        drop(download_phase);
11529        #[cfg(feature = "bench-internals")]
11530        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
11531        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
11532        if blob_id_for(&bytes) != blob_id {
11533            return Err(simple(format!(
11534                "blob content address mismatch for {blob_id}"
11535            )));
11536        }
11537        #[cfg(feature = "bench-internals")]
11538        drop(validate_phase);
11539        #[cfg(feature = "bench-internals")]
11540        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
11541        let now = self.clock_now_ms();
11542        self.conn
11543            .execute(
11544                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
11545                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
11546            )
11547            .map_err(|e| simple(e.to_string()))?;
11548        self.conn
11549            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
11550            .map_err(|e| simple(e.to_string()))?;
11551        self.enforce_blob_cache_cap();
11552        #[cfg(feature = "bench-internals")]
11553        drop(insert_phase);
11554        self.get_cached_blob_bytes(&blob_id)
11555            .map_err(simple)?
11556            .ok_or_else(|| simple("blob cache write failed".to_owned()))
11557    }
11558
11559    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
11560        #[cfg(feature = "bench-internals")]
11561        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
11562        let mut stmt = self
11563            .conn
11564            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
11565            .map_err(|e| e.to_string())?;
11566        let mut rows = stmt
11567            .query(rusqlite::params![blob_id])
11568            .map_err(|e| e.to_string())?;
11569        if let Some(row) = rows.next().map_err(|e| e.to_string())? {
11570            let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
11571            let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
11572            let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
11573            return Ok(Some(FetchedBlob {
11574                blob_id: blob_id.to_owned(),
11575                byte_length,
11576                bytes,
11577                media_type,
11578            }));
11579        }
11580        Ok(None)
11581    }
11582
11583    /// §5.9.7 B4: upload every queued blob before push.
11584    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
11585        let pending: Vec<(String, Option<String>)> = {
11586            let mut stmt = self
11587                .conn
11588                .prepare(
11589                    "SELECT blob_id, media_type FROM (
11590                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
11591                      UNION ALL
11592                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
11593                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
11594                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
11595                    ) ORDER BY created_at_ms, blob_id",
11596                )
11597                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11598            let rows = stmt
11599                .query_map([], |row| {
11600                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
11601                })
11602                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11603            rows.collect::<Result<_, _>>()
11604                .map_err(|error| match error {
11605                    rusqlite::Error::InvalidColumnType(..)
11606                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
11607                        "sync.local_corrupt",
11608                        "Pending blob upload metadata is invalid",
11609                    ),
11610                    _ => TransportError::new("client.failed", error.to_string()),
11611                })?
11612        };
11613        for (blob_id, media_type) in pending {
11614            let stored: Option<(SqlValue, SqlValue)> = self
11615                .conn
11616                .query_row(
11617                    "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
11618                    rusqlite::params![blob_id],
11619                    |row| Ok((row.get(0)?, row.get(1)?)),
11620                )
11621                .optional()
11622                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11623            let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
11624                return Err(TransportError::new(
11625                    "sync.local_corrupt",
11626                    "Pending blob upload body is missing or corrupt",
11627                ));
11628            };
11629            if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
11630                return Err(TransportError::new(
11631                    "sync.local_corrupt",
11632                    "Pending blob upload body is missing or corrupt",
11633                ));
11634            }
11635            self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
11636            self.conn
11637                .execute(
11638                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
11639                    rusqlite::params![blob_id],
11640                )
11641                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11642        }
11643        Ok(())
11644    }
11645
11646    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
11647    /// grant when the transport supports it, else streaming through the direct
11648    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
11649    /// PUTs direct with no host auth; on a grant PUT failure the client streams
11650    /// through the direct endpoint — a *different, host-authenticated
11651    /// capability*, not a fall-through of the grant's authority.
11652    fn upload_one(
11653        &self,
11654        transport: &mut dyn Transport,
11655        blob_id: &str,
11656        bytes: &[u8],
11657        media_type: Option<&str>,
11658    ) -> Result<(), TransportError> {
11659        match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
11660            BlobUploadGrant::Present => return Ok(()), // idempotent §5.9.3
11661            BlobUploadGrant::Url {
11662                url,
11663                url_expires_at_ms,
11664            } => {
11665                let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
11666                if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
11667                    return Ok(());
11668                }
11669                // Failed/expired grant PUT — stream through the direct endpoint.
11670            }
11671            BlobUploadGrant::None => {
11672                // No presign store — stream through the direct endpoint.
11673            }
11674        }
11675        transport.blob_upload(blob_id, bytes, media_type)
11676    }
11677
11678    fn visible_blob_ids_sql(&self) -> String {
11679        let mut selects = Vec::new();
11680        for table in &self.schema.tables {
11681            for column in table
11682                .columns
11683                .iter()
11684                .filter(|column| column.ty == ColumnType::BlobRef)
11685            {
11686                let identifier = quote_ident(&column.name);
11687                selects.push(format!(
11688                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
11689                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
11690                     AND json_type({identifier}, '$.blobId') = 'text'",
11691                    quote_ident(&table.name),
11692                ));
11693            }
11694        }
11695        if selects.is_empty() {
11696            "SELECT NULL AS blob_id WHERE 0".to_owned()
11697        } else {
11698            selects.join(" UNION ")
11699        }
11700    }
11701
11702    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
11703    /// staged uploads pin bodies directly; no derived refcount is stored.
11704    fn enforce_blob_cache_cap(&self) {
11705        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
11706            return;
11707        };
11708        let mut total: i64 = self
11709            .conn
11710            .query_row(
11711                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
11712                [],
11713                |row| row.get(0),
11714            )
11715            .unwrap_or(0);
11716        if total <= max_bytes {
11717            return;
11718        }
11719        let query = format!(
11720            "SELECT blob_id, byte_length FROM _syncular_blobs
11721             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11722               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11723               AND blob_id NOT IN ({})
11724             ORDER BY created_at_ms ASC, blob_id ASC",
11725            self.visible_blob_ids_sql(),
11726        );
11727        let candidates: Vec<(String, i64)> = {
11728            let Ok(mut stmt) = self.conn.prepare(&query) else {
11729                return;
11730            };
11731            let Ok(rows) = stmt.query_map([], |row| {
11732                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11733            }) else {
11734                return;
11735            };
11736            rows.filter_map(Result::ok).collect()
11737        };
11738        for (blob_id, byte_length) in candidates {
11739            if total <= max_bytes {
11740                break;
11741            }
11742            let _ = self.conn.execute(
11743                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
11744                rusqlite::params![blob_id],
11745            );
11746            total -= byte_length;
11747        }
11748    }
11749
11750    fn delete_unreferenced_cached_blobs(&mut self) {
11751        #[cfg(feature = "bench-internals")]
11752        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11753        if !self.schema_has_blobs() {
11754            return;
11755        }
11756        self.rebuild_overlay_if_dirty();
11757        let _ = self.conn.execute(
11758            &format!(
11759                "DELETE FROM _syncular_blobs
11760                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11761                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11762                   AND blob_id NOT IN ({})",
11763                self.visible_blob_ids_sql(),
11764            ),
11765            [],
11766        );
11767    }
11768
11769    // -- local row storage ----------------------------------------------------------
11770
11771    /// The cached per-table primary-key upsert SQL for `full_table` (see
11772    /// the `insert_sql` field: built once, reused per row).
11773    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11774        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11775            return sql.clone();
11776        }
11777        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11778        columns.push(quote_ident("_syncular_version"));
11779        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11780        let primary_key = quote_ident(&table.primary_key);
11781        let updates = columns
11782            .iter()
11783            .filter(|column| **column != primary_key)
11784            .map(|column| format!("{column}=excluded.{column}"))
11785            .collect::<Vec<_>>()
11786            .join(", ");
11787        let sql = format!(
11788            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
11789            columns.join(", "),
11790            placeholders.join(", ")
11791        );
11792        self.insert_sql
11793            .borrow_mut()
11794            .insert(full_table.to_owned(), sql.clone());
11795        sql
11796    }
11797
11798    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
11799        self.overlay_dirty.set(true);
11800        self.write_row(&base_table(table_name), table_name, row, version)
11801    }
11802
11803    fn write_row(
11804        &self,
11805        full_table: &str,
11806        table_name: &str,
11807        row: &Row,
11808        version: i64,
11809    ) -> Result<(), String> {
11810        #[cfg(feature = "bench-internals")]
11811        let _phase = self.benchmark_phases.start(Phase::RowWrite);
11812        let table = self
11813            .schema
11814            .table(table_name)
11815            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11816        let sql = self.insert_row_sql(full_table, table);
11817        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11818        let params = row
11819            .iter()
11820            .map(RowParam::Cell)
11821            .chain(std::iter::once(RowParam::Version(version)));
11822        stmt.execute(rusqlite::params_from_iter(params))
11823            .map_err(|e| e.to_string())?;
11824        Ok(())
11825    }
11826
11827    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
11828        let table = self
11829            .schema
11830            .table(table_name)
11831            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11832        self.overlay_dirty.set(true);
11833        let sql = format!(
11834            "DELETE FROM {} WHERE {}",
11835            base_table(table_name),
11836            row_id_predicate(table)
11837        );
11838        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11839        stmt.execute(rusqlite::params![row_id])
11840            .map_err(|e| e.to_string())?;
11841        Ok(())
11842    }
11843
11844    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
11845    /// the outbox changed since the last rebuild (the no-op sync round).
11846    fn rebuild_overlay_if_dirty(&mut self) {
11847        if self.overlay_dirty.get() {
11848            self.rebuild_overlay();
11849        }
11850    }
11851
11852    /// §7.1: local reads see outbox state applied optimistically — rebuild
11853    /// every visible table as (base server state) + (pending outbox replay
11854    /// on top). Optimistic rows carry version `-1`.
11855    fn rebuild_overlay(&mut self) {
11856        #[cfg(feature = "bench-internals")]
11857        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11858        #[cfg(test)]
11859        self.overlay_rebuild_count
11860            .set(self.overlay_rebuild_count.get() + 1);
11861        self.exec("SAVEPOINT syncular_overlay");
11862        for table in self.schema.tables.clone() {
11863            for index in &table.fts_indexes {
11864                let _ = self.drop_fts_triggers(index);
11865            }
11866            let visible = visible_table(&table.name);
11867            let base = base_table(&table.name);
11868            self.exec(&format!("DELETE FROM {visible}"));
11869            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
11870        }
11871        for commit in &self.outbox {
11872            self.apply_outbox_ops(&commit.ops);
11873        }
11874        for table in self.schema.tables.clone() {
11875            for index in &table.fts_indexes {
11876                let _ = self.rebuild_fts_projection(&table, index);
11877                let _ = self.create_fts_triggers(&table, index);
11878            }
11879        }
11880        self.exec("RELEASE syncular_overlay");
11881        self.overlay_dirty.set(false);
11882    }
11883
11884    /// §7.1 replay: every operation is applied over the current visible
11885    /// state. An operation that no longer applies (a server row now holds its
11886    /// unique value) stays pending and invisible until a later replay admits
11887    /// it or the server answers its push.
11888    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
11889        #[cfg(feature = "bench-internals")]
11890        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11891        for op in ops {
11892            #[cfg(feature = "bench-internals")]
11893            if let Some(phase) = &mut phase {
11894                phase.unit();
11895            }
11896            let _ = self.apply_outbox_op(op);
11897        }
11898    }
11899
11900    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
11901        let Some(table) = self.schema.table(&op.table) else {
11902            return Ok(());
11903        };
11904        let visible = visible_table(&table.name);
11905        if !op.upsert {
11906            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
11907            self.conn
11908                .prepare_cached(&sql)
11909                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
11910                .map_err(|e| e.to_string())?;
11911            return Ok(());
11912        }
11913        let Some(values) = op.values.as_ref() else {
11914            return Ok(());
11915        };
11916        // §7.1: the overlay applies the operation's PRESENT columns
11917        // over the current local row. A partial operation over an
11918        // absent local row leaves it absent — the server answers it
11919        // with sync.row_deleted or sync.row_missing (§6.2).
11920        let full = table
11921            .columns
11922            .iter()
11923            .all(|column| values.contains_key(&column.name));
11924        let local = self.visible_row(table, &op.row_id);
11925        if local.is_none() && !full {
11926            return Ok(());
11927        }
11928        let mut row: Row = Vec::with_capacity(table.columns.len());
11929        for (index, column) in table.columns.iter().enumerate() {
11930            let incoming = match values.get(&column.name) {
11931                Some(raw) => json_to_column_value(column, Some(raw)),
11932                None => Ok(local
11933                    .as_ref()
11934                    .and_then(|(values, _)| values.get(index).cloned())
11935                    .flatten()),
11936            };
11937            match incoming {
11938                Ok(value) => row.push(value),
11939                Err(_) => return Ok(()),
11940            }
11941        }
11942        let version = local.as_ref().map_or(-1, |(_, version)| *version);
11943        self.write_row(&visible, &table.name, &row, version)
11944            .map_err(|error| {
11945                if error.starts_with("UNIQUE constraint failed:") {
11946                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
11947                } else {
11948                    error
11949                }
11950            })
11951    }
11952
11953    /// The visible (optimistic) row's values plus version, or `None` when the
11954    /// row is absent from the overlay.
11955    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
11956        let sql = format!(
11957            "SELECT * FROM {} WHERE {}",
11958            visible_table(&table.name),
11959            row_id_predicate(table)
11960        );
11961        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
11962        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
11963        let row = rows.next().ok()??;
11964        let mut values = Vec::with_capacity(table.columns.len());
11965        for (index, column) in table.columns.iter().enumerate() {
11966            let raw = row.get_ref(index).ok()?;
11967            let json = sql_ref_to_json(column, raw);
11968            values.push(json_to_column_value(column, Some(&json)).ok()?);
11969        }
11970        let version: i64 = row.get(table.columns.len()).ok()?;
11971        Some((values, version))
11972    }
11973
11974    fn exec(&self, sql: &str) {
11975        let _ = self.conn.execute_batch(sql);
11976    }
11977
11978    // -- realtime (§8) ---------------------------------------------------------------
11979
11980    /// §8.8: the configured realtime policy.
11981    #[must_use]
11982    pub fn realtime_policy(&self) -> RealtimePolicy {
11983        self.realtime_policy
11984    }
11985
11986    /// §8.8: the explicit realtime availability state.
11987    #[must_use]
11988    pub fn realtime_state(&self) -> RealtimeState {
11989        self.realtime_state
11990    }
11991
11992    /// §8.8: set the realtime policy. Refused while the socket is connected:
11993    /// a policy change never silently re-routes an ownership state.
11994    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
11995        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
11996            return Err(
11997                "sync.invalid_request: disconnect realtime before changing the realtime policy"
11998                    .to_owned(),
11999            );
12000        }
12001        self.realtime_policy = policy;
12002        match policy {
12003            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
12004            RealtimePolicy::Required | RealtimePolicy::Optional
12005                if self.realtime_state == RealtimeState::Disabled =>
12006            {
12007                self.set_realtime_state(RealtimeState::Disconnected, None);
12008            }
12009            RealtimePolicy::Required | RealtimePolicy::Optional => {}
12010        }
12011        Ok(())
12012    }
12013
12014    /// Record an availability transition, keeping the reason code bounded and
12015    /// code-like (§7.6) and clearing it where it cannot apply.
12016    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
12017        self.realtime_state = state;
12018        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
12019        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
12020            self.realtime_reason_code = None;
12021            self.realtime_retry_delay_ms = None;
12022        }
12023    }
12024
12025    #[must_use]
12026    fn realtime_connected(&self) -> bool {
12027        self.realtime_state == RealtimeState::Connected
12028    }
12029
12030    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
12031        if self.realtime_policy == RealtimePolicy::Off {
12032            return Err(
12033                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
12034            );
12035        }
12036        if self.realtime_connected() {
12037            return Ok(());
12038        }
12039        self.realtime_state = RealtimeState::Connecting;
12040        match transport.realtime_connect_for_client(&self.client_id) {
12041            Ok(()) => {
12042                self.set_realtime_state(RealtimeState::Connected, None);
12043                Ok(())
12044            }
12045            Err(error) => {
12046                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
12047                Err(format!("{}: {}", error.code, error.message))
12048            }
12049        }
12050    }
12051
12052    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
12053        // A deliberately disconnected or policy-disabled client has nothing
12054        // to release; a `lost` socket still does (the host owns the handle).
12055        if self.realtime_state == RealtimeState::Disconnected
12056            || self.realtime_state == RealtimeState::Disabled
12057        {
12058            return;
12059        }
12060        let _ = transport.realtime_close();
12061        self.set_realtime_state(
12062            if self.realtime_policy == RealtimePolicy::Off {
12063                RealtimeState::Disabled
12064            } else {
12065                RealtimeState::Disconnected
12066            },
12067            None,
12068        );
12069        self.presence.clear(); // §8.6.1: presence is per-connection
12070    }
12071
12072    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
12073    /// document for `scope_key`. Requires a live socket; the document is
12074    /// ephemeral (lost on disconnect). Authorization is the connection's
12075    /// registration (§8.6.3) — an unheld key is rejected loudly by the
12076    /// server with `presence.forbidden`.
12077    pub fn set_presence(
12078        &mut self,
12079        transport: &mut dyn Transport,
12080        scope_key: &str,
12081        doc: Option<&Value>,
12082    ) -> Result<(), String> {
12083        if !self.realtime_connected() {
12084            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
12085        }
12086        let text = encode_presence_publish(scope_key, doc);
12087        transport
12088            .realtime_send(&text)
12089            .map_err(|e| format!("{}: {}", e.code, e.message))
12090    }
12091
12092    /// §8.6: the peers currently present on a scope key (ephemeral).
12093    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
12094        self.presence
12095            .get(scope_key)
12096            .map(|peers| peers.values().cloned().collect())
12097            .unwrap_or_default()
12098    }
12099
12100    /// §8.6 apply an inbound presence fanout to the local map.
12101    fn apply_presence(
12102        &mut self,
12103        scope_key: String,
12104        kind: Option<PresenceKind>,
12105        actor_id: Option<String>,
12106        client_id: Option<String>,
12107        doc: Option<Value>,
12108        error: Option<String>,
12109    ) {
12110        // The publisher-directed error variant is out-of-band; nothing to
12111        // record in the peer map.
12112        if error.is_some() {
12113            return;
12114        }
12115        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
12116            return;
12117        };
12118        let peer_key = format!("{actor_id} {client_id}");
12119        match kind {
12120            PresenceKind::Leave => {
12121                if let Some(peers) = self.presence.get_mut(&scope_key) {
12122                    peers.remove(&peer_key);
12123                    if peers.is_empty() {
12124                        self.presence.remove(&scope_key);
12125                    }
12126                }
12127            }
12128            _ => {
12129                let doc = match doc {
12130                    Some(Value::Object(_)) => doc.unwrap(),
12131                    _ => return,
12132                };
12133                self.presence.entry(scope_key).or_default().insert(
12134                    peer_key,
12135                    PresencePeer {
12136                        actor_id,
12137                        client_id,
12138                        doc,
12139                    },
12140                );
12141            }
12142        }
12143    }
12144
12145    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
12146    pub fn on_realtime_text(&mut self, text: &str) {
12147        match parse_control(text) {
12148            Ok(ControlMessage::Hello { requires_sync, .. }) => {
12149                if requires_sync {
12150                    // §8.1: pull before trusting the socket for continuity.
12151                    self.set_sync_needed(true, true);
12152                }
12153            }
12154            Ok(ControlMessage::Presence {
12155                scope_key,
12156                kind,
12157                actor_id,
12158                client_id,
12159                doc,
12160                error,
12161                ..
12162            }) => {
12163                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
12164            }
12165            Ok(ControlMessage::Wake { .. }) => {
12166                // §8.3: any wake-up means "run a pull soon", never data.
12167                self.set_sync_needed(true, true);
12168            }
12169            _ => {}
12170        }
12171    }
12172
12173    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
12174    /// a pull response per section; an unapplied delta is a wake-up.
12175    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
12176        if self.stopped {
12177            return;
12178        }
12179        #[cfg(feature = "bench-internals")]
12180        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
12181        let message = match decode_message(bytes) {
12182            Ok(m) if m.msg_kind == MsgKind::Response => m,
12183            _ => {
12184                self.set_sync_needed(true, true);
12185                return;
12186            }
12187        };
12188        #[cfg(feature = "bench-internals")]
12189        drop(decode_phase);
12190        let mut frames = message.frames.into_iter();
12191        let mut applied_cursor: Option<i64> = None;
12192        let mut any_covered = false;
12193        let mut dropped = false;
12194        while let Some(frame) = frames.next() {
12195            let Frame::SubStart {
12196                id,
12197                status,
12198                effective_scopes,
12199                ..
12200            } = frame
12201            else {
12202                continue;
12203            };
12204            let mut body = Vec::new();
12205            let mut next_cursor: Option<i64> = None;
12206            for inner in frames.by_ref() {
12207                match inner {
12208                    Frame::SubEnd {
12209                        next_cursor: nc, ..
12210                    } => {
12211                        next_cursor = Some(nc);
12212                        break;
12213                    }
12214                    Frame::Unknown { .. } => {}
12215                    other => body.push(other),
12216                }
12217            }
12218            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
12219                dropped = true;
12220                continue;
12221            };
12222            let sub = &self.subs[sub_index];
12223            // §8.2: only locally active, not mid-bootstrap subscriptions
12224            // apply; skipped sections are repaired by the next pull.
12225            if status != SubStatus::Active
12226                || sub.state != SubState::Active
12227                || sub.bootstrap_state.is_some()
12228                || !sub.synced_once
12229            {
12230                dropped = true;
12231                continue;
12232            }
12233            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
12234                // Idempotent redelivery of an already-covered window.
12235                any_covered = true;
12236                continue;
12237            }
12238            let previous_effective = self.subs[sub_index].effective.clone();
12239            let previous_cursor = self.subs[sub_index].cursor;
12240            self.subs[sub_index].effective = Some(effective_scopes);
12241            let mut failed = false;
12242            for inner in body {
12243                match inner {
12244                    Frame::Commit {
12245                        tables, changes, ..
12246                    } => {
12247                        if self.apply_commit_frame(&tables, &changes).is_err() {
12248                            failed = true;
12249                            break;
12250                        }
12251                    }
12252                    _ => {
12253                        failed = true;
12254                        break;
12255                    }
12256                }
12257            }
12258            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
12259                self.apply_sub_end(sub_index, cursor, None)
12260                    .ok()
12261                    .map(|()| cursor)
12262            });
12263            let Some(next_cursor) = committed else {
12264                self.subs[sub_index].effective = previous_effective;
12265                self.subs[sub_index].cursor = previous_cursor;
12266                dropped = true;
12267                continue;
12268            };
12269            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
12270        }
12271        if let Some(cursor) = applied_cursor {
12272            // §8.2 ack point: the highest applied SUB_END.nextCursor.
12273            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12274            let _ = transport.realtime_send(&ack);
12275        } else if !any_covered || dropped {
12276            // §8.2: a delta not applied at all is treated as a wake-up.
12277            self.set_sync_needed(true, true);
12278        }
12279    }
12280
12281    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
12282    /// cursor across active, non-bootstrapping subscriptions that have
12283    /// synced at least once. No such subscription, no ack.
12284    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
12285        if !self.realtime_connected() {
12286            return;
12287        }
12288        let floor = self
12289            .subs
12290            .iter()
12291            .filter(|s| {
12292                s.state == SubState::Active
12293                    && s.bootstrap_state.is_none()
12294                    && s.synced_once
12295                    && s.cursor >= 0
12296            })
12297            .map(|s| s.cursor)
12298            .min();
12299        if let Some(cursor) = floor {
12300            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12301            let _ = transport.realtime_send(&ack);
12302        }
12303    }
12304}
12305
12306#[cfg(test)]
12307mod previous_version_wiring_tests {
12308    use super::*;
12309    use serde_json::{json, Value};
12310
12311    fn previous_version_schema(version: i32) -> Value {
12312        json!({
12313            "version": version,
12314            "tables": [{
12315                "name": "tasks",
12316                "primaryKey": "id",
12317                "columns": [
12318                    { "name": "id", "type": "string", "nullable": false },
12319                    { "name": "note", "type": "string", "nullable": true }
12320                ],
12321                "scopes": []
12322            }]
12323        })
12324    }
12325
12326    fn previous_version_limits() -> crate::api::ClientLimits {
12327        crate::api::ClientLimits {
12328            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
12329                enabled: true,
12330                ..Default::default()
12331            }),
12332            ..Default::default()
12333        }
12334    }
12335
12336    /// The configuration the live client carries, for a direct capture call.
12337    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
12338        previous_version_limits()
12339            .previous_version_context
12340            .expect("configured")
12341    }
12342
12343    /// Capture the container the way a bump would, so a read test starts from a
12344    /// container this client's own state will then decide about.
12345    fn recapture_previous_version(client: &SyncClient) {
12346        let replica_path = client
12347            .previous_version_replica_path()
12348            .expect("file-backed replica");
12349        let outcome = crate::previous_version::capture_previous_version_from_replica(
12350            &client.conn,
12351            &replica_path,
12352            2,
12353            2,
12354            &previous_version_config(),
12355            1,
12356        )
12357        .expect("capture");
12358        assert!(matches!(
12359            outcome,
12360            crate::previous_version::CaptureOutcome::Captured(_)
12361        ));
12362    }
12363
12364    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
12365        Subscription {
12366            id: "wiring-sub".to_owned(),
12367            table: "tasks".to_owned(),
12368            requested: Vec::new(),
12369            params: None,
12370            cursor,
12371            bootstrap_state: bootstrap_state.map(str::to_owned),
12372            state,
12373            reason_code: None,
12374            effective: None,
12375            synced_once: false,
12376        }
12377    }
12378
12379    #[test]
12380    fn previous_version_read_reasons_come_from_live_client_state() {
12381        use crate::previous_version::{
12382            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
12383        };
12384        let path = std::env::temp_dir()
12385            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
12386            .to_string_lossy()
12387            .into_owned();
12388        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
12389        {
12390            let mut setup = SyncClient::open_path(
12391                "wiring".to_owned(),
12392                &previous_version_schema(1),
12393                previous_version_limits(),
12394                &path,
12395            )
12396            .expect("v1 install");
12397            setup
12398                .mutate(vec![Mutation::Upsert {
12399                    table: "tasks".to_owned(),
12400                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12401                    base_version: None,
12402                }])
12403                .expect("seed a row");
12404        }
12405        let mut client = SyncClient::open_path(
12406            "wiring".to_owned(),
12407            &previous_version_schema(2),
12408            previous_version_limits(),
12409            &path,
12410        )
12411        .expect("v2 bump captures");
12412        assert!(std::path::Path::new(&container).exists());
12413        let spec = PreviousVersionReadSpec {
12414            table: "tasks".to_owned(),
12415            ..Default::default()
12416        };
12417
12418        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
12419        recapture_previous_version(&client);
12420        client.lease_state = Some(LeaseState {
12421            lease_id: None,
12422            expires_at_ms: None,
12423            error_code: Some("lease.expired".to_owned()),
12424        });
12425        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12426        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12427        assert!(!snapshot.available);
12428        assert!(!std::path::Path::new(&container).exists());
12429
12430        // An expired lease uses the client clock (injected, no sleeps).
12431        recapture_previous_version(&client);
12432        client.set_now_ms(1_000);
12433        client.lease_state = Some(LeaseState {
12434            lease_id: Some("lease".to_owned()),
12435            expires_at_ms: Some(1_000),
12436            error_code: None,
12437        });
12438        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12439        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12440        assert!(!std::path::Path::new(&container).exists());
12441        client.lease_state = None;
12442
12443        // A revoked subscription ⇒ scope-revoked.
12444        recapture_previous_version(&client);
12445        client.subs.push(subscription(SubState::Revoked, 0, None));
12446        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12447        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
12448        assert!(!std::path::Path::new(&container).exists());
12449        client.subs.clear();
12450
12451        // Coverage completion: every ACTIVE subscription has a cursor and no
12452        // resume token — a still-resuming subscription is not completion.
12453        recapture_previous_version(&client);
12454        client
12455            .subs
12456            .push(subscription(SubState::Active, 0, Some("resume")));
12457        assert!(!client.previous_version_coverage_complete());
12458        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12459        assert!(snapshot.available);
12460        assert_eq!(snapshot.previous_version, Some(2));
12461        assert_eq!(snapshot.rows.len(), 1);
12462
12463        recapture_previous_version(&client);
12464        client.subs[0].bootstrap_state = None;
12465        assert!(client.previous_version_coverage_complete());
12466        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12467        assert_eq!(
12468            snapshot.reason,
12469            Some(PreviousVersionReason::CoverageComplete)
12470        );
12471        assert!(!std::path::Path::new(&container).exists());
12472        client.subs.clear();
12473
12474        // With no active subscription at all, coverage is NOT complete.
12475        assert!(!client.previous_version_coverage_complete());
12476
12477        drop(client);
12478        let _ = std::fs::remove_file(&path);
12479    }
12480
12481    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
12482    /// come from live state; this proves the OTHER caller —
12483    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
12484    /// actually removes the container for lease-inactivity, coverage
12485    /// completion and scope revocation, and leaves it alone while the
12486    /// replacement bootstrap is still resuming.
12487    #[test]
12488    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
12489        let path = std::env::temp_dir()
12490            .join(format!(
12491                "syncular-prev-lifetime-{}.db",
12492                uuid::Uuid::new_v4()
12493            ))
12494            .to_string_lossy()
12495            .into_owned();
12496        let container = format!(
12497            "{path}{}",
12498            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
12499        );
12500        let exists = || std::path::Path::new(&container).exists();
12501        {
12502            let mut setup = SyncClient::open_path(
12503                "lifetime".to_owned(),
12504                &previous_version_schema(1),
12505                previous_version_limits(),
12506                &path,
12507            )
12508            .expect("v1 install");
12509            setup
12510                .mutate(vec![Mutation::Upsert {
12511                    table: "tasks".to_owned(),
12512                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12513                    base_version: None,
12514                }])
12515                .expect("seed a row");
12516        }
12517        let mut client = SyncClient::open_path(
12518            "lifetime".to_owned(),
12519            &previous_version_schema(2),
12520            previous_version_limits(),
12521            &path,
12522        )
12523        .expect("v2 bump captures");
12524        // Pin the clock so the 24h TTL never fires in this test (the TTL has
12525        // its own coverage); the capture record's createdAtMs is 1.
12526        client.set_now_ms(1_000);
12527        assert!(exists());
12528
12529        // §7.3.5 lease error ⇒ the lifetime check drops the container.
12530        client.lease_state = Some(LeaseState {
12531            lease_id: None,
12532            expires_at_ms: None,
12533            error_code: Some("lease.expired".to_owned()),
12534        });
12535        client.previous_version_lifetime_check();
12536        assert!(!exists());
12537        client.lease_state = None;
12538
12539        // §7.4.5 coverage completion (every active sub has a cursor and no
12540        // resume token) ⇒ drop.
12541        recapture_previous_version(&client);
12542        client.subs.push(subscription(SubState::Active, 0, None));
12543        client.previous_version_lifetime_check();
12544        assert!(!exists());
12545        client.subs.clear();
12546
12547        // §3.3 scope revocation ⇒ drop.
12548        recapture_previous_version(&client);
12549        client.subs.push(subscription(SubState::Revoked, 0, None));
12550        client.previous_version_lifetime_check();
12551        assert!(!exists());
12552        client.subs.clear();
12553
12554        // Negative control: a still-resuming active subscription is NOT
12555        // coverage completion, so the container survives the same call.
12556        recapture_previous_version(&client);
12557        client
12558            .subs
12559            .push(subscription(SubState::Active, 0, Some("resume")));
12560        client.previous_version_lifetime_check();
12561        assert!(exists());
12562        client.subs.clear();
12563
12564        drop(client);
12565        let _ = std::fs::remove_file(&path);
12566        let _ = std::fs::remove_file(&container);
12567        let _ = std::fs::remove_file(format!("{container}-wal"));
12568        let _ = std::fs::remove_file(format!("{container}-shm"));
12569    }
12570}