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, RetainedCommitRow, RowState,
39    SchemaFloor, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot,
40    TableChange, WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
41    CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
42    MAX_DIAGNOSTIC_QUERY_FAILURES,
43};
44use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
45#[cfg(feature = "bench-internals")]
46use crate::bench::{Phase, Recorder};
47use crate::previous_version::{
48    capture_previous_version_from_replica, first_incompatibility,
49    reconcile_previous_version_at_boot, set_local_schema_descriptor,
50    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
51    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
52};
53use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
54use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
55use crate::values::{
56    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
57    encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
58    normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
59};
60
61/// §4.2 default: rows and SQLite images, both applied in committed chunks.
62/// Bit 3 (signed URLs, §5.4) is
63/// added per transport capability at request-build time.
64const DEFAULT_ACCEPT: u8 = 0b0111;
65const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
66const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
67const ACCEPT_SQLITE: u8 = 1 << 2;
68const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
69const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
70
71/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
72pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
73const LOCAL_REVISION_KEY: &str = "localRevision";
74const CLIENT_ID_KEY: &str = "clientId";
75const LEASE_STATE_KEY: &str = "leaseState";
76const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
77const LOG_EPOCH_KEY: &str = "logEpoch";
78/// Persisted fail-closed quarantine marker: present while a security preflight
79/// is pending and `activateSecurity` has yet to run. Storing it in the replica
80/// keeps the gate with the data it protects, so a rebuilt host handle reopening
81/// the same file returns in preflight even though its in-memory state is fresh.
82const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
83const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
84const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
85/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
86/// the new schema after a bump. Never a wire code (§10.3).
87const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
88/// §5.11 client-local code: an encode at the push seam resolved no usable key
89/// id or named an unknown key. Never a wire code (§10.3).
90const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
91
92#[derive(Debug, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94struct PersistedLocalDataRebootstrapReceipt {
95    version: u8,
96    retained_commits: u64,
97    reset_subscriptions: u64,
98}
99
100fn invalid_local_rebootstrap_receipt() -> String {
101    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
102}
103
104fn encode_local_rebootstrap_receipt(
105    retained_commits: usize,
106    reset_subscriptions: usize,
107) -> Result<String, String> {
108    let retained_commits =
109        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
110    let reset_subscriptions =
111        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
112    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
113        return Err(invalid_local_rebootstrap_receipt());
114    }
115    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
116        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
117        retained_commits,
118        reset_subscriptions,
119    })
120    .map_err(|_| invalid_local_rebootstrap_receipt())
121}
122
123fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
124    // Pre-0.15.36 markers proved application but did not retain the receipt.
125    if value == "v1" {
126        return Ok((0, 0));
127    }
128    let receipt: PersistedLocalDataRebootstrapReceipt =
129        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
130    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
131        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
132        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
133    {
134        return Err(invalid_local_rebootstrap_receipt());
135    }
136    Ok((
137        usize::try_from(receipt.retained_commits)
138            .map_err(|_| invalid_local_rebootstrap_receipt())?,
139        usize::try_from(receipt.reset_subscriptions)
140            .map_err(|_| invalid_local_rebootstrap_receipt())?,
141    ))
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum SubState {
146    Active,
147    Revoked,
148    Failed,
149}
150
151#[cfg(test)]
152mod observation_tests {
153    use super::*;
154    use crate::native_transport::HostTransport;
155    use crate::values::encode_row_json;
156    use serde_json::json;
157
158    fn client() -> SyncClient {
159        SyncClient::new(
160            "retry-test".to_owned(),
161            &json!({
162                "version": 1,
163                "tables": [{
164                    "name": "tasks",
165                    "primaryKey": "id",
166                    "columns": [
167                        { "name": "id", "type": "string", "nullable": false },
168                        { "name": "project_id", "type": "string", "nullable": false }
169                    ],
170                    "scopes": [{ "pattern": "project:{project_id}" }]
171                }]
172            }),
173            ClientLimits::default(),
174        )
175        .expect("test client")
176    }
177
178    #[cfg(feature = "e2ee")]
179    #[test]
180    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
181        let schema = json!({
182            "version": 1,
183            "tables": [{
184                "name": "secrets",
185                "primaryKey": "id",
186                "columns": [
187                    { "name": "id", "type": "string", "nullable": false },
188                    { "name": "encryption_key_id", "type": "string", "nullable": true },
189                    { "name": "note", "type": "bytes", "nullable": true,
190                      "encrypted": true, "declaredType": "string" }
191                ],
192                "scopes": []
193            }]
194        });
195        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
196            .expect("test client");
197        let mut config = crate::values::EncryptionConfig::default();
198        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
199        config
200            .key_id_columns
201            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
202        client.set_encryption(config);
203        // A patch on a locally absent row presents an encrypted column with no
204        // stored key id to fall back to. Authoring succeeds...
205        let commit_id = client
206            .patch(
207                "secrets",
208                "ghost",
209                Map::from_iter([("note".to_owned(), json!("x"))]),
210                None,
211            )
212            .expect("patch records without an author-time encrypt failure");
213        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
214        // ...and the push seam raises a durable local rejection, not a throw.
215        assert!(client.drop_unencodable_outbox().unwrap());
216        assert!(client.pending_commit_ids().is_empty());
217        let rejections = client.rejections();
218        assert_eq!(rejections.len(), 1);
219        assert_eq!(rejections[0].code, "client.encrypt_failed");
220        assert_eq!(rejections[0].client_commit_id, commit_id);
221        assert_eq!(
222            client
223                .commit_outcome(&commit_id)
224                .unwrap()
225                .map(|outcome| outcome.status),
226            Some(CommitOutcomeStatus::Rejected)
227        );
228    }
229
230    #[test]
231    fn patch_with_a_scope_column_matches_the_stored_local_row() {
232        let mut client = client();
233        client.create_synced_tables().unwrap();
234        client
235            .conn
236            .execute(
237                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
238                [],
239            )
240            .unwrap();
241
242        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
243        // the scope column leaves the presence set.
244        client
245            .patch(
246                "tasks",
247                "t1",
248                Map::from_iter([
249                    ("project_id".to_owned(), json!("p1")),
250                    ("id".to_owned(), json!("t1")),
251                ]),
252                None,
253            )
254            .expect("an equal scope column is a no-op");
255        let values = client.outbox[0].ops[0]
256            .values
257            .clone()
258            .expect("the patch records values");
259        assert_eq!(values.get("id"), Some(&json!("t1")));
260        assert!(!values.contains_key("project_id"));
261
262        // A differing value stays rejected.
263        let differing = client.patch(
264            "tasks",
265            "t1",
266            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
267            None,
268        );
269        assert!(differing
270            .expect_err("a differing scope column is rejected")
271            .contains("patch cannot write scope column"));
272
273        // An absent local row leaves nothing to prove equality against, so
274        // the patch fails closed.
275        let absent = client.patch(
276            "tasks",
277            "ghost",
278            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
279            None,
280        );
281        assert!(absent
282            .expect_err("an absent local row is rejected")
283            .contains("patch cannot write scope column"));
284        assert_eq!(client.pending_commit_ids().len(), 1);
285    }
286
287    #[test]
288    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
289        let schema = json!({
290            "version": 1,
291            "tables": [
292                {
293                    "name": "tenants",
294                    "primaryKey": "tenant_id",
295                    "columns": [
296                        { "name": "tenant_id", "type": "string", "nullable": false },
297                        { "name": "body", "type": "string", "nullable": false }
298                    ],
299                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
300                },
301                {
302                    "name": "buckets",
303                    "primaryKey": "id",
304                    "columns": [
305                        { "name": "id", "type": "string", "nullable": false },
306                        { "name": "bucket", "type": "float", "nullable": false },
307                        { "name": "body", "type": "string", "nullable": false }
308                    ],
309                    "scopes": [{ "pattern": "bucket:{bucket}" }]
310                }
311            ]
312        });
313        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
314            .expect("test client");
315        client.create_synced_tables().unwrap();
316
317        // A primary key that is also a scope column with no local row: the
318        // sparse payload's key is the row id being patched, so its value is
319        // proven equal without a stored-row read.
320        client
321            .patch(
322                "tenants",
323                "t1",
324                Map::from_iter([
325                    ("tenant_id".to_owned(), json!("t1")),
326                    ("body".to_owned(), json!("created")),
327                ]),
328                None,
329            )
330            .expect("the primary-key scope column is proven equal by construction");
331
332        // A stored REAL scope value coerces from a supplied integer.
333        client
334            .conn
335            .execute(
336                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
337                [],
338            )
339            .unwrap();
340        client
341            .patch(
342                "buckets",
343                "b1",
344                Map::from_iter([
345                    ("bucket".to_owned(), json!(2)),
346                    ("body".to_owned(), json!("coerced")),
347                ]),
348                None,
349            )
350            .expect("the stored REAL value coerces from a supplied integer");
351        let values = client.outbox[1].ops[0]
352            .values
353            .clone()
354            .expect("the patch records values");
355        assert!(!values.contains_key("bucket"));
356
357        // A differing value stays rejected.
358        let differing = client.patch(
359            "buckets",
360            "b1",
361            Map::from_iter([("bucket".to_owned(), json!(3))]),
362            None,
363        );
364        assert!(differing
365            .expect_err("a differing scope value is rejected")
366            .contains("patch cannot write scope column"));
367    }
368
369    #[cfg(feature = "e2ee")]
370    #[test]
371    fn stored_key_fallback_resolves_an_integer_primary_key() {
372        let schema = json!({
373            "version": 1,
374            "tables": [{
375                "name": "patients",
376                "primaryKey": "id",
377                "columns": [
378                    { "name": "id", "type": "integer", "nullable": false },
379                    { "name": "encryption_key_id", "type": "string", "nullable": true },
380                    { "name": "note", "type": "bytes", "nullable": true,
381                      "encrypted": true, "declaredType": "string" }
382                ],
383                "scopes": []
384            }]
385        });
386        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
387            .expect("test client");
388        client.create_synced_tables().unwrap();
389        client
390            .conn
391            .execute(
392                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
393                 VALUES (5, 'k1', NULL, 1)",
394                [],
395            )
396            .unwrap();
397        let mut config = crate::values::EncryptionConfig::default();
398        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
399        config
400            .key_id_columns
401            .insert("patients".to_owned(), "encryption_key_id".to_owned());
402        client.set_encryption(config);
403        let table = client.schema.table("patients").unwrap();
404        let fallback = client
405            .stored_key_fallback(table, "5")
406            .expect("the stored integer key resolves the fallback");
407        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
408    }
409
410    #[test]
411    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
412        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
413            {"name":"id","type":"string","nullable":false},
414            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
415        for state in ["absent", "cached", "pinned"] {
416            let path = std::env::temp_dir().join(format!(
417                "syncular-blob-stage-{}.sqlite",
418                uuid::Uuid::new_v4()
419            ));
420            let mut client = SyncClient::open_path(
421                "stage-test".into(),
422                &schema,
423                Default::default(),
424                path.to_str().unwrap(),
425            )
426            .unwrap();
427            let bytes = b"atomic stage";
428            if state != "absent" {
429                client
430                    .upload_blob(bytes, Some("text/plain".into()), None)
431                    .unwrap();
432                if state == "cached" {
433                    client
434                        .conn
435                        .execute("DELETE FROM _syncular_blob_uploads", [])
436                        .unwrap();
437                }
438            }
439            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
440            client
441                .conn
442                .execute_batch(
443                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
444                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
445                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
446                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
447                )
448                .unwrap();
449            let error = client
450                .upload_blob(bytes, Some("application/octet-stream".into()), None)
451                .unwrap_err();
452            assert!(error.contains("injected stage failure"), "{state}: {error}");
453            assert!(client.conn.is_autocommit());
454            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
455            drop(client);
456            let mut reopened = SyncClient::open_path(
457                "stage-test".into(),
458                &schema,
459                Default::default(),
460                path.to_str().unwrap(),
461            )
462            .unwrap();
463            let durable = reopened
464                .query("SELECT * FROM _syncular_blobs", &[])
465                .unwrap();
466            reopened
467                .conn
468                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
469                .unwrap();
470            let retried = reopened.upload_blob(bytes, None, None).unwrap();
471            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
472            assert_eq!(
473                reopened
474                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
475                    .unwrap(),
476                vec![Map::from_iter([(
477                    "blob_id".into(),
478                    Value::from(blob_id_for(bytes)),
479                )])]
480            );
481            drop(reopened);
482            std::fs::remove_file(&path).unwrap();
483            assert_eq!(durable, bodies, "{state}: reopened body state");
484            assert_eq!(immediate, bodies, "{state}: immediate body state");
485        }
486    }
487
488    #[test]
489    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
490        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
491            {"name":"id","type":"string","nullable":false},
492            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
493        for fault in [
494            "missing",
495            "body-type",
496            "length",
497            "hash",
498            "pin-metadata",
499            "read",
500            "pin-delete",
501        ] {
502            let path = std::env::temp_dir().join(format!(
503                "syncular-blob-upload-{}.sqlite",
504                uuid::Uuid::new_v4()
505            ));
506            let mut client = SyncClient::open_path(
507                "upload-test".into(),
508                &schema,
509                Default::default(),
510                path.to_str().unwrap(),
511            )
512            .unwrap();
513            let bytes = b"durable upload";
514            let ref_value = client
515                .upload_blob(bytes, Some("text/plain".into()), None)
516                .unwrap();
517            let commit = client
518                .mutate(vec![Mutation::Upsert {
519                    table: "attachments".into(),
520                    values: Map::from_iter([
521                        ("id".into(), Value::from("pending")),
522                        (
523                            "file".into(),
524                            Value::from(format!(
525                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
526                                blob_id_for(bytes),
527                                bytes.len()
528                            )),
529                        ),
530                    ]),
531                    base_version: None,
532                }])
533                .unwrap();
534            client
535                .conn
536                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
537                .unwrap();
538            client.conn.execute_batch(match fault {
539                "missing" => "DELETE FROM _syncular_blobs",
540                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
541                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
542                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
543                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
544                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
545                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
546            }).unwrap();
547            let state = if fault == "read" {
548                Vec::new()
549            } else {
550                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
551            };
552            let mut transport = CountingRealtimeTransport::default();
553            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
554            assert_eq!(
555                error.code,
556                if matches!(fault, "read" | "pin-delete") {
557                    "client.failed"
558                } else {
559                    "sync.local_corrupt"
560                },
561                "{fault}"
562            );
563            assert_eq!(
564                transport.blob_uploads.len(),
565                usize::from(fault == "pin-delete"),
566                "{fault}: corrupt bytes must never be uploaded"
567            );
568            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
569            if fault != "read" {
570                assert_eq!(
571                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
572                    state
573                );
574            } else {
575                client
576                    .conn
577                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
578                    .unwrap();
579            }
580            drop(client);
581            let mut reopened = SyncClient::open_path(
582                "upload-test".into(),
583                &schema,
584                Default::default(),
585                path.to_str().unwrap(),
586            )
587            .unwrap();
588            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
589            if fault != "read" {
590                assert_eq!(
591                    reopened
592                        .query("SELECT * FROM _syncular_blobs", &[])
593                        .unwrap(),
594                    state
595                );
596            }
597            if fault == "pin-delete" {
598                reopened
599                    .conn
600                    .execute_batch("DROP TRIGGER fail_pin_delete")
601                    .unwrap();
602            }
603            if fault == "pin-metadata" {
604                reopened
605                    .conn
606                    .execute(
607                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
608                        [],
609                    )
610                    .unwrap();
611            }
612            reopened
613                .conn
614                .execute_batch(
615                    "DELETE FROM _syncular_blobs;
616                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
617                )
618                .unwrap();
619            transport.blob_uploads.clear();
620            reopened.flush_blob_uploads(&mut transport).unwrap();
621            assert_eq!(
622                transport.blob_uploads,
623                [(blob_id_for(bytes), bytes.to_vec())]
624            );
625            assert_eq!(
626                reopened
627                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
628                    .unwrap(),
629                Vec::<Map<String, Value>>::new()
630            );
631            assert_eq!(reopened.pending_commit_ids(), [commit]);
632            assert_eq!(
633                reopened
634                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
635                    .unwrap()
636                    .unwrap()
637                    .bytes,
638                bytes
639            );
640            drop(reopened);
641            std::fs::remove_file(path).unwrap();
642        }
643    }
644
645    #[test]
646    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
647        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
648            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
649            "scopes":[{"pattern":"project:{project_id}"}]}]});
650        for realtime in [false, true] {
651            for fault in [
652                "none",
653                "row",
654                "revision",
655                "commit",
656                "cursor",
657                "error",
658                "missing-end",
659            ] {
660                if realtime && fault == "missing-end" {
661                    continue;
662                }
663                let path = std::env::temp_dir()
664                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
665                let mut client = SyncClient::open_path(
666                    "frame-test".into(),
667                    &schema,
668                    Default::default(),
669                    path.to_str().unwrap(),
670                )
671                .unwrap();
672                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
673                client
674                    .subscribe(
675                        "tasks".into(),
676                        "tasks".into(),
677                        vec![("project_id".into(), vec!["p1".into()])],
678                        None,
679                    )
680                    .unwrap();
681                client.subs[0].cursor = 0;
682                client.subs[0].synced_once = true;
683                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
684                client.persist_sub(&client.subs[0]).unwrap();
685                client.realtime_state = RealtimeState::Connected;
686                client.realtime_reason_code = None;
687                client.drain_change_batches();
688                let mut transport = CountingRealtimeTransport::default();
689                let (_, meta) = client.build_request(false);
690                let mut response = Message {
691                    wire_version: WIRE_VERSION,
692                    msg_kind: MsgKind::Response,
693                    frames: vec![
694                        Frame::RespHeader {
695                            required_schema_version: None,
696                            latest_schema_version: None,
697                            log_epoch: Some("epoch-1".into()),
698                            reset_required: Some(false),
699                        },
700                        Frame::SubStart {
701                            id: "tasks".into(),
702                            status: SubStatus::Active,
703                            reason_code: "".into(),
704                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
705                            bootstrap: false,
706                        },
707                    ],
708                };
709                for (index, id) in ["first", "second"].iter().enumerate() {
710                    let values = Map::from_iter([
711                        ("id".into(), Value::from(*id)),
712                        ("project_id".into(), Value::from("p1")),
713                    ]);
714                    response.frames.push(Frame::Commit {
715                        commit_seq: index as i64 + 1,
716                        actor_id: "writer".into(),
717                        created_at_ms: 1,
718                        tables: vec!["tasks".into()],
719                        changes: vec![ssp2::model::Change {
720                            table_index: 0,
721                            row_id: (*id).into(),
722                            op: Op::Upsert,
723                            row_version: Some(index as i64 + 1),
724                            scopes: vec![("project_id".into(), "p1".into())],
725                            row: Some(
726                                encode_row_json(
727                                    client.schema.table("tasks").unwrap(),
728                                    id,
729                                    &values,
730                                    &client.encryption,
731                                )
732                                .unwrap(),
733                            ),
734                        }],
735                    });
736                }
737                response.frames.push(Frame::SubEnd {
738                    next_cursor: 2,
739                    bootstrap_state: None,
740                });
741                let valid = response.clone();
742                match fault {
743                    "row" => {
744                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
745                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
746                    },
747                    "revision" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND NEW.value = '2' AND EXISTS (SELECT 1 FROM _syncular_base_tasks WHERE id = 'second') BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END").unwrap(),
748                    "commit" => client.conn.execute_batch("PRAGMA foreign_keys = ON; CREATE TABLE frame_parent(id INTEGER PRIMARY KEY); CREATE TABLE frame_child(id INTEGER REFERENCES frame_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_frame AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'second' BEGIN INSERT INTO frame_child VALUES(1); END").unwrap(),
749                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
750                    "error" => {response.frames.truncate(3);response.frames.push(Frame::Error {code:"sync.invalid_request".into(),message:"later error".into(),category:"protocol".into(),retryable:false,recommended_action:"retry".into(),details:None});},
751                    "missing-end" => {response.frames.truncate(3);},
752                    _ => {}
753                }
754                if realtime {
755                    client.on_realtime_binary(&mut transport, &encode_message(&response));
756                } else {
757                    assert_eq!(
758                        matches!(
759                            client.process_response(&mut transport, response, &meta),
760                            SyncOutcome::Ok(_)
761                        ),
762                        fault == "none",
763                        "{fault}"
764                    );
765                }
766                assert!(
767                    client.conn.is_autocommit(),
768                    "no open transaction after {realtime}/{fault}"
769                );
770                let count = if matches!(fault, "none" | "cursor") {
771                    2
772                } else {
773                    1
774                };
775                let expected_revision = count + u64::from(realtime && fault != "none");
776                assert_eq!(
777                    client.local_revision(),
778                    expected_revision,
779                    "{realtime}/{fault}"
780                );
781                let batches = client.drain_change_batches();
782                assert_eq!(
783                    batches.len(),
784                    expected_revision as usize,
785                    "{realtime}/{fault}"
786                );
787                let row_batches: Vec<_> = batches
788                    .iter()
789                    .filter(|batch| !batch.tables.is_empty())
790                    .collect();
791                assert_eq!(row_batches.len(), count as usize);
792                assert_eq!(
793                    row_batches
794                        .iter()
795                        .map(|b| b.revision.clone())
796                        .collect::<Vec<_>>(),
797                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
798                );
799                for table in ["tasks", "_syncular_base_tasks"] {
800                    assert_eq!(
801                        client
802                            .conn
803                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
804                                .get::<_, u64>(0))
805                            .unwrap(),
806                        count,
807                        "{realtime}/{fault}/{table}"
808                    );
809                }
810                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
811                assert_eq!(
812                    transport
813                        .messages
814                        .iter()
815                        .filter(|m| m.contains("\"ack\""))
816                        .count(),
817                    usize::from(fault == "none")
818                );
819                if matches!(fault, "revision" | "commit" | "cursor") {
820                    client
821                        .conn
822                        .execute_batch("DROP TRIGGER fail_frame")
823                        .unwrap();
824                }
825                drop(client);
826                let mut reopened = SyncClient::open_path(
827                    "frame-test".into(),
828                    &schema,
829                    Default::default(),
830                    path.to_str().unwrap(),
831                )
832                .unwrap();
833                assert_eq!(reopened.local_revision(), expected_revision);
834                assert_eq!(
835                    reopened
836                        .query("SELECT id FROM tasks ORDER BY id", &[])
837                        .unwrap()
838                        .len(),
839                    count as usize
840                );
841                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
842                let (_, meta) = reopened.build_request(false);
843                assert!(matches!(
844                    reopened.process_response(&mut transport, valid, &meta),
845                    SyncOutcome::Ok(_)
846                ));
847                assert_eq!(
848                    reopened
849                        .query("SELECT id FROM tasks ORDER BY id", &[])
850                        .unwrap()
851                        .len(),
852                    2
853                );
854                assert_eq!(reopened.subs[0].cursor, 2);
855                drop(reopened);
856                std::fs::remove_file(path).unwrap();
857            }
858        }
859    }
860
861    #[test]
862    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
863        for status in [SubStatus::Reset, SubStatus::Revoked] {
864            for fault in ["subscription", "revision"] {
865                let mut client = client();
866                client
867                    .subscribe(
868                        "tasks".into(),
869                        "tasks".into(),
870                        vec![("project_id".into(), vec!["p1".into()])],
871                        None,
872                    )
873                    .unwrap();
874                client.subs[0].cursor = 41;
875                client.subs[0].synced_once = true;
876                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
877                client.persist_sub(&client.subs[0]).unwrap();
878                client
879                    .mutate(vec![Mutation::Upsert {
880                        table: "tasks".into(),
881                        values: Map::from_iter([
882                            ("id".into(), Value::from("pending")),
883                            ("project_id".into(), Value::from("p1")),
884                        ]),
885                        base_version: None,
886                    }])
887                    .unwrap();
888                let ids = client.pending_commit_ids();
889                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
890                let revision = client.local_revision();
891                client.drain_change_batches();
892                let (_, meta) = client.build_request(false);
893                client.conn.execute_batch(if fault == "subscription" {
894                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
895                } else {
896                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
897                }).unwrap();
898                let mut transport = CountingRealtimeTransport::default();
899                let mut report = SyncReport::default();
900                // Reset changes window completeness and therefore needs a revision.
901                if status == SubStatus::Reset {
902                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
903                }
904                let result = client.process_section(
905                    &mut transport,
906                    "tasks",
907                    status,
908                    "sync.scope_revoked",
909                    vec![],
910                    vec![],
911                    Some((42, None)),
912                    &meta,
913                    &mut report,
914                );
915                assert!(result.is_err(), "{status:?}/{fault}");
916                assert!(client.conn.is_autocommit());
917                assert_eq!(client.subs[0].cursor, 41);
918                assert_eq!(client.subs[0].state, SubState::Active);
919                assert_eq!(client.pending_commit_ids(), ids);
920                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
921                assert!(client.rejections.is_empty());
922                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
923                assert_eq!(client.local_revision(), revision);
924                assert!(client.drain_change_batches().is_empty());
925                assert!(report.revoked.is_empty() && report.resets.is_empty());
926            }
927        }
928    }
929
930    #[test]
931    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
932        let mut client = client();
933        client
934            .subscribe(
935                "tasks".into(),
936                "tasks".into(),
937                vec![("project_id".into(), vec!["p1".into()])],
938                None,
939            )
940            .unwrap();
941        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
942        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
943            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
944        let row = |id: &str| ssp2::segment::SegmentRow {
945            server_version: 1,
946            values: vec![
947                Some(ColumnValue::String(id.into())),
948                Some(ColumnValue::String("p1".into())),
949            ],
950        };
951        let segment = RowsSegment {
952            table: "tasks".into(),
953            schema_version: 1,
954            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
955            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
956        };
957        assert!(client.apply_segment(0, &segment, true).is_err());
958        assert!(client.conn.is_autocommit());
959        assert_eq!(
960            client
961                .query("SELECT id FROM tasks ORDER BY id", &[])
962                .unwrap(),
963            vec![Map::from_iter([("id".into(), Value::from("first"))])]
964        );
965        assert_eq!(client.local_revision(), 1);
966        assert_eq!(client.drain_change_batches().len(), 1);
967        assert_eq!(client.subs[0].cursor, -1);
968        client
969            .conn
970            .execute_batch("DROP TRIGGER fail_block")
971            .unwrap();
972        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
973        assert_eq!(client.local_revision(), 3);
974        assert_eq!(client.drain_change_batches().len(), 2);
975    }
976
977    #[test]
978    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
979        assert_eq!(
980            client().diagnostics_storage().blob_cache_bytes_approx,
981            Some(0)
982        );
983        let client = SyncClient::new(
984            "diagnostic-storage".to_owned(),
985            &json!({
986                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
987                    "columns": [{"name": "id", "type": "string", "nullable": false},
988                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
989            }),
990            ClientLimits {
991                blob_cache_max_bytes: Some(5),
992                ..Default::default()
993            },
994        )
995        .unwrap();
996        let empty = client.diagnostics_storage();
997        assert_eq!(empty.status, "healthy");
998        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
999        assert_eq!(empty.retained_outcome_entries, Some(0));
1000        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1001        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1002        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1003            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1004                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1005            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1006                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1007        let written = client.diagnostics_storage();
1008        assert_eq!(written.status, "pressure");
1009        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1010        assert_eq!(written.retained_outcome_entries, Some(2));
1011        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1012        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1013        assert_eq!(
1014            written.pressure_reason_code.as_deref(),
1015            Some("client.blob_cache_over_limit")
1016        );
1017        client
1018            .conn
1019            .execute_batch(
1020                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1021            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1022            )
1023            .unwrap();
1024        let staged = client.diagnostics_storage();
1025        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1026        assert_eq!(staged.retained_outcome_entries, Some(0));
1027        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1028        assert_eq!(staged.status, "healthy");
1029        client.conn.execute_batch("ROLLBACK").unwrap();
1030        assert_eq!(client.diagnostics_storage(), written);
1031        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1032        let unreadable = client.diagnostics_storage();
1033        assert_eq!(unreadable.status, "unreadable");
1034        assert!(unreadable.database_bytes_approx.is_none());
1035        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1036        assert!(unreadable.retained_outcome_entries.is_none());
1037        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1038        assert!(unreadable.blob_cache_bytes_approx.is_none());
1039        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1040        let restored = client.diagnostics_storage();
1041        assert_eq!(restored.status, written.status);
1042        assert_eq!(
1043            restored.retained_outcome_bytes_approx,
1044            written.retained_outcome_bytes_approx
1045        );
1046        client.conn.set_prepared_statement_cache_capacity(0);
1047        assert_eq!(client.diagnostics_storage(), restored);
1048    }
1049
1050    /// Answers every round with one server `ERROR` frame (§1.6).
1051    struct ServerErrorTransport {
1052        code: &'static str,
1053        retryable: bool,
1054    }
1055
1056    impl Transport for ServerErrorTransport {
1057        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1058            Ok(encode_message(&Message {
1059                wire_version: WIRE_VERSION,
1060                msg_kind: MsgKind::Response,
1061                frames: vec![
1062                    Frame::RespHeader {
1063                        required_schema_version: None,
1064                        latest_schema_version: None,
1065                        log_epoch: Some("epoch-1".to_owned()),
1066                        reset_required: Some(false),
1067                    },
1068                    Frame::Error {
1069                        code: self.code.to_owned(),
1070                        message: "server error".to_owned(),
1071                        category: "internal".to_owned(),
1072                        retryable: self.retryable,
1073                        recommended_action: "retryLater".to_owned(),
1074                        details: None,
1075                    },
1076                ],
1077            }))
1078        }
1079
1080        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1081            self.sync(request)
1082        }
1083
1084        fn download_segment(
1085            &mut self,
1086            _request: &SegmentRequest,
1087            _on_progress: &mut dyn FnMut(u64),
1088        ) -> Result<Vec<u8>, TransportError> {
1089            Err(TransportError::new("sync.transport_failed", "offline"))
1090        }
1091
1092        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1093            Ok(())
1094        }
1095
1096        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1097            Ok(())
1098        }
1099
1100        fn realtime_close(&mut self) -> Result<(), TransportError> {
1101            Ok(())
1102        }
1103    }
1104
1105    #[test]
1106    fn retryable_server_error_schedules_a_background_retry() {
1107        let mut client = client();
1108        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1109        let mut transport = ServerErrorTransport {
1110            code: "sync.internal_error",
1111            retryable: true,
1112        };
1113        assert!(matches!(
1114            client.sync(&mut transport),
1115            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1116        ));
1117        assert!(matches!(
1118            client.drain_sync_intents().as_slice(),
1119            [SyncIntent::Background { delay_ms: 250 }]
1120        ));
1121        assert_eq!(
1122            client.progress().snapshot().unwrap().retry_delay_ms,
1123            Some(250)
1124        );
1125        let mut transport = ServerErrorTransport {
1126            code: "sync.invalid_request",
1127            retryable: false,
1128        };
1129        assert!(matches!(
1130            client.sync(&mut transport),
1131            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1132        ));
1133        assert!(client.drain_sync_intents().is_empty());
1134        let progress = client.progress().snapshot().unwrap();
1135        assert_eq!(progress.state, ProgressState::Failed);
1136        assert_eq!(progress.retry_delay_ms, None);
1137    }
1138
1139    #[test]
1140    fn failed_progress_retry_delay_doubles_to_the_cap() {
1141        let mut client = client();
1142        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1143        let mut transport = ServerErrorTransport {
1144            code: "sync.internal_error",
1145            retryable: true,
1146        };
1147        let delays: Vec<Option<u64>> = (0..9)
1148            .map(|_| {
1149                client.sync(&mut transport);
1150                client.progress().snapshot().unwrap().retry_delay_ms
1151            })
1152            .collect();
1153        assert_eq!(
1154            delays,
1155            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1156        );
1157    }
1158
1159    #[test]
1160    fn background_retry_deadlines_back_off_and_reset() {
1161        let mut client = client();
1162        client.schedule_background_retry();
1163        assert!(matches!(
1164            client.drain_sync_intents().as_slice(),
1165            [SyncIntent::Background { delay_ms: 250 }]
1166        ));
1167        client.schedule_background_retry();
1168        assert!(matches!(
1169            client.drain_sync_intents().as_slice(),
1170            [SyncIntent::Background { delay_ms: 500 }]
1171        ));
1172        client.reset_background_retry();
1173        client.schedule_background_retry();
1174        assert!(matches!(
1175            client.drain_sync_intents().as_slice(),
1176            [SyncIntent::Background { delay_ms: 250 }]
1177        ));
1178    }
1179
1180    #[test]
1181    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1182        let mut client = client();
1183        client
1184            .subscribe(
1185                "epoch-tasks".to_owned(),
1186                "tasks".to_owned(),
1187                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1188                None,
1189            )
1190            .expect("subscribe");
1191        client.drain_sync_intents();
1192
1193        client
1194            .run_log_epoch_reset("epoch-2")
1195            .expect("reset partition log epoch");
1196
1197        assert!(matches!(
1198            client.drain_sync_intents().as_slice(),
1199            [SyncIntent::Interactive]
1200        ));
1201    }
1202
1203    #[derive(Default)]
1204    struct CountingRealtimeTransport {
1205        connects: usize,
1206        closes: usize,
1207        messages: Vec<String>,
1208        blob_uploads: Vec<(String, Vec<u8>)>,
1209        blob_download: Option<Vec<u8>>,
1210        sync_calls: usize,
1211        realtime_sync_calls: usize,
1212        refuse_connect: bool,
1213    }
1214
1215    impl Transport for CountingRealtimeTransport {
1216        fn blob_upload(
1217            &mut self,
1218            blob_id: &str,
1219            bytes: &[u8],
1220            _media_type: Option<&str>,
1221        ) -> Result<(), TransportError> {
1222            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1223            Ok(())
1224        }
1225
1226        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1227            self.blob_download
1228                .clone()
1229                .map(BlobDownload::Bytes)
1230                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1231        }
1232
1233        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1234            self.sync_calls += 1;
1235            Err(TransportError::new("sync.transport_failed", "offline"))
1236        }
1237
1238        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1239            self.realtime_sync_calls += 1;
1240            Err(TransportError::new("sync.transport_failed", "offline"))
1241        }
1242
1243        fn download_segment(
1244            &mut self,
1245            _request: &SegmentRequest,
1246            _on_progress: &mut dyn FnMut(u64),
1247        ) -> Result<Vec<u8>, TransportError> {
1248            Err(TransportError::new("sync.transport_failed", "offline"))
1249        }
1250
1251        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1252            self.connects += 1;
1253            if self.refuse_connect {
1254                return Err(TransportError::new("transport.failed", "refused"));
1255            }
1256            Ok(())
1257        }
1258
1259        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1260            self.messages.push(text.to_owned());
1261            Ok(())
1262        }
1263
1264        fn realtime_close(&mut self) -> Result<(), TransportError> {
1265            self.closes += 1;
1266            Ok(())
1267        }
1268    }
1269
1270    #[test]
1271    fn blob_results_own_downloaded_bytes() {
1272        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1273            {"name":"id","type":"string","nullable":false},
1274            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1275        let bytes = b"owned typed result".to_vec();
1276        let blob_id = blob_id_for(&bytes);
1277        let mut transport = CountingRealtimeTransport {
1278            blob_download: Some(bytes.clone()),
1279            ..Default::default()
1280        };
1281        let result = {
1282            let mut client =
1283                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1284                    .unwrap();
1285            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1286            assert_eq!(result.blob_id, blob_id);
1287            assert_eq!(result.byte_length, bytes.len() as i64);
1288            assert_eq!(result.bytes, bytes);
1289            assert_eq!(result.media_type, None);
1290            assert_eq!(
1291                client
1292                    .conn
1293                    .query_row(
1294                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1295                        rusqlite::params![blob_id],
1296                        |row| row.get::<_, i64>(0),
1297                    )
1298                    .unwrap(),
1299                0
1300            );
1301            result
1302        };
1303        assert_eq!(result.bytes, bytes);
1304
1305        let mut client =
1306            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1307        assert_eq!(
1308            client
1309                .fetch_blob_bytes(&mut transport, "not json")
1310                .unwrap_err(),
1311            (
1312                "client.failed".to_owned(),
1313                "blob ref is not JSON".to_owned()
1314            )
1315        );
1316    }
1317
1318    #[test]
1319    fn visible_blob_references_survive_cache_trimming() {
1320        let schema = json!({"version":1,"tables":[
1321            {"name":"attachments","primaryKey":"id","columns":[
1322                {"name":"id","type":"string","nullable":false},
1323                {"name":"body","type":"blob_ref","nullable":true},
1324                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1325            {"name":"avatars","primaryKey":"id","columns":[
1326                {"name":"id","type":"string","nullable":false},
1327                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1328        ]});
1329        let bytes = b"referenced above cap".to_vec();
1330        let blob_id = blob_id_for(&bytes);
1331        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1332        let mut client = SyncClient::new(
1333            "visible-blob-refs".to_owned(),
1334            &schema,
1335            ClientLimits {
1336                blob_cache_max_bytes: Some(1),
1337                ..Default::default()
1338            },
1339        )
1340        .unwrap();
1341        client.conn.execute(
1342            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1343            rusqlite::params![reference, reference, reference, reference],
1344        ).unwrap();
1345        client
1346            .conn
1347            .execute(
1348                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1349                rusqlite::params![reference],
1350            )
1351            .unwrap();
1352        client.conn.execute(
1353            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1354            [],
1355        ).unwrap();
1356        let mut transport = CountingRealtimeTransport {
1357            blob_download: Some(bytes.clone()),
1358            ..Default::default()
1359        };
1360
1361        assert_eq!(
1362            client
1363                .fetch_blob_bytes(&mut transport, &blob_id)
1364                .unwrap()
1365                .bytes,
1366            bytes
1367        );
1368        let rows = client
1369            .conn
1370            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1371            .unwrap()
1372            .query_map([], |row| row.get::<_, String>(0))
1373            .unwrap()
1374            .collect::<Result<Vec<_>, _>>()
1375            .unwrap();
1376        assert_eq!(rows, vec![blob_id]);
1377    }
1378
1379    #[test]
1380    fn realtime_connection_ownership_is_idempotent() {
1381        let mut client = client();
1382        let mut transport = CountingRealtimeTransport {
1383            connects: 0,
1384            closes: 0,
1385            ..Default::default()
1386        };
1387        client
1388            .connect_realtime(&mut transport)
1389            .expect("first connect");
1390        client
1391            .connect_realtime(&mut transport)
1392            .expect("idempotent connect");
1393        assert_eq!(transport.connects, 1);
1394        client.disconnect_realtime(&mut transport);
1395        client.disconnect_realtime(&mut transport);
1396        assert_eq!(transport.closes, 1);
1397        client
1398            .connect_realtime(&mut transport)
1399            .expect("deliberate reconnect");
1400        assert_eq!(transport.connects, 2);
1401    }
1402
1403    #[test]
1404    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1405        let mut client = client();
1406        client
1407            .set_realtime_policy(RealtimePolicy::Required)
1408            .expect("set policy");
1409        let mut transport = CountingRealtimeTransport::default();
1410        match client.sync(&mut transport) {
1411            SyncOutcome::RealtimeUnavailable {
1412                state,
1413                reason_code,
1414                retry_delay_ms,
1415            } => {
1416                assert_eq!(state, RealtimeState::Disconnected);
1417                assert_eq!(reason_code, None);
1418                assert_eq!(retry_delay_ms, 250);
1419            }
1420            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1421        }
1422        assert_eq!(transport.sync_calls, 0);
1423        assert!(matches!(
1424            client.drain_sync_intents().as_slice(),
1425            [SyncIntent::Background { delay_ms: 250 }]
1426        ));
1427        let snapshot = client
1428            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1429            .expect("diagnostics");
1430        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1431        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1432        assert_eq!(snapshot.host.realtime_reason_code, None);
1433        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1434        assert_eq!(
1435            snapshot
1436                .last_round
1437                .as_ref()
1438                .and_then(|round| round.error_code.clone()),
1439            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1440        );
1441    }
1442
1443    #[test]
1444    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1445        let mut client = client();
1446        client
1447            .set_realtime_policy(RealtimePolicy::Required)
1448            .expect("set policy");
1449        let mut transport = CountingRealtimeTransport {
1450            refuse_connect: true,
1451            ..Default::default()
1452        };
1453        assert!(client.connect_realtime(&mut transport).is_err());
1454        assert_eq!(client.realtime_state(), RealtimeState::Refused);
1455        let snapshot = client
1456            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1457            .expect("diagnostics");
1458        assert_eq!(
1459            snapshot.host.realtime_reason_code.as_deref(),
1460            Some("transport.failed")
1461        );
1462        match client.sync(&mut transport) {
1463            SyncOutcome::RealtimeUnavailable {
1464                state, reason_code, ..
1465            } => {
1466                assert_eq!(state, RealtimeState::Refused);
1467                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1468            }
1469            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1470        }
1471        assert_eq!(transport.sync_calls, 0);
1472        assert_eq!(transport.realtime_sync_calls, 0);
1473    }
1474
1475    #[test]
1476    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1477        let mut client = client();
1478        client
1479            .set_realtime_policy(RealtimePolicy::Required)
1480            .expect("set policy");
1481        let mut transport = CountingRealtimeTransport::default();
1482        client.connect_realtime(&mut transport).expect("connect");
1483        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1484        assert!(
1485            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1486            "a policy change cannot silently re-route a connected socket"
1487        );
1488        assert!(matches!(
1489            client.sync(&mut transport),
1490            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1491        ));
1492        assert_eq!(client.realtime_state(), RealtimeState::Lost);
1493        assert_eq!(transport.realtime_sync_calls, 1);
1494        assert_eq!(transport.sync_calls, 0);
1495        let snapshot = client
1496            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1497            .expect("diagnostics");
1498        assert_eq!(
1499            snapshot.host.realtime_reason_code.as_deref(),
1500            Some("sync.transport_failed")
1501        );
1502        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1503        match client.sync(&mut transport) {
1504            SyncOutcome::RealtimeUnavailable {
1505                state, reason_code, ..
1506            } => {
1507                assert_eq!(state, RealtimeState::Lost);
1508                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1509            }
1510            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1511        }
1512        assert_eq!(transport.sync_calls, 0);
1513        client.disconnect_realtime(&mut transport);
1514        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1515        assert_eq!(transport.closes, 1);
1516        client.connect_realtime(&mut transport).expect("reconnect");
1517        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1518        assert_eq!(transport.connects, 2);
1519    }
1520
1521    #[test]
1522    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1523        let mut client = client();
1524        client
1525            .set_realtime_policy(RealtimePolicy::Off)
1526            .expect("set policy");
1527        let mut transport = CountingRealtimeTransport::default();
1528        assert!(client.connect_realtime(&mut transport).is_err());
1529        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1530        assert!(matches!(
1531            client.sync(&mut transport),
1532            SyncOutcome::Failed { .. }
1533        ));
1534        assert_eq!(transport.sync_calls, 1);
1535        assert_eq!(transport.realtime_sync_calls, 0);
1536        let snapshot = client
1537            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1538            .expect("diagnostics");
1539        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1540        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1541    }
1542
1543    #[test]
1544    fn optional_policy_keeps_the_http_round() {
1545        let mut client = client();
1546        let mut transport = CountingRealtimeTransport::default();
1547        assert!(matches!(
1548            client.sync(&mut transport),
1549            SyncOutcome::Failed { .. }
1550        ));
1551        assert_eq!(transport.sync_calls, 1);
1552        assert_eq!(transport.realtime_sync_calls, 0);
1553        let snapshot = client
1554            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1555            .expect("diagnostics");
1556        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1557        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1558    }
1559
1560    #[test]
1561    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1562        let mut client = client();
1563        client
1564            .subscribe(
1565                "repair-tasks".to_owned(),
1566                "tasks".to_owned(),
1567                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1568                None,
1569            )
1570            .expect("subscribe");
1571        {
1572            let sub = client
1573                .subs
1574                .iter_mut()
1575                .find(|sub| sub.id == "repair-tasks")
1576                .expect("subscription");
1577            sub.cursor = 42;
1578            sub.synced_once = true;
1579            let persisted = sub.clone();
1580            client.persist_sub(&persisted).unwrap();
1581        }
1582        for table in ["_syncular_base_tasks", "tasks"] {
1583            client
1584                .conn
1585                .execute(
1586                    &format!(
1587                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1588                    ),
1589                    rusqlite::params!["server-row", "p1"],
1590                )
1591                .expect("seed server row");
1592        }
1593        let pending = client
1594            .mutate(vec![Mutation::Upsert {
1595                table: "tasks".to_owned(),
1596                values: Map::from_iter([
1597                    ("id".to_owned(), Value::from("offline-row")),
1598                    ("project_id".to_owned(), Value::from("p1")),
1599                ]),
1600                base_version: None,
1601            }])
1602            .expect("queue offline work");
1603        client.drain_change_batches();
1604        client.drain_sync_intents();
1605
1606        assert_eq!(
1607            client
1608                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1609                    rebootstrap_id: "support-case-001".to_owned(),
1610                })
1611                .expect("rebootstrap"),
1612            LocalDataRebootstrapResult {
1613                already_applied: false,
1614                retained_commits: 1,
1615                reset_subscriptions: 1,
1616            }
1617        );
1618        let visible_ids = client
1619            .conn
1620            .prepare("SELECT id FROM tasks ORDER BY id")
1621            .expect("prepare visible ids")
1622            .query_map([], |row| row.get::<_, String>(0))
1623            .expect("query visible ids")
1624            .collect::<Result<Vec<_>, _>>()
1625            .expect("collect visible ids");
1626        assert_eq!(visible_ids, vec!["offline-row"]);
1627        assert_eq!(client.pending_commit_ids(), vec![pending]);
1628        assert_eq!(
1629            client
1630                .subscription_state("repair-tasks")
1631                .expect("subscription")
1632                .cursor,
1633            -1
1634        );
1635        assert!(client.upgrading());
1636        assert!(client.sync_needed());
1637        assert!(matches!(
1638            client.drain_sync_intents().as_slice(),
1639            [SyncIntent::Interactive]
1640        ));
1641        assert_eq!(client.drain_change_batches().len(), 1);
1642
1643        assert_eq!(
1644            client
1645                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1646                    rebootstrap_id: "support-case-001".to_owned(),
1647                })
1648                .expect("idempotent retry"),
1649            LocalDataRebootstrapResult {
1650                already_applied: true,
1651                retained_commits: 1,
1652                reset_subscriptions: 1,
1653            }
1654        );
1655        assert!(client.drain_change_batches().is_empty());
1656    }
1657
1658    #[test]
1659    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1660        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1661        assert_eq!(
1662            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1663            (3, 4)
1664        );
1665        assert_eq!(
1666            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1667            (0, 0)
1668        );
1669        for malformed in [
1670            "",
1671            "{}",
1672            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1673            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1674            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1675        ] {
1676            assert_eq!(
1677                decode_local_rebootstrap_receipt(malformed)
1678                    .expect_err("malformed receipt must fail"),
1679                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1680            );
1681        }
1682    }
1683
1684    #[test]
1685    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1686        let path = std::env::temp_dir().join(format!(
1687            "syncular-rebootstrap-receipt-{}.db",
1688            uuid::Uuid::new_v4()
1689        ));
1690        let schema = json!({
1691            "version": 1,
1692            "tables": [{
1693                "name": "tasks",
1694                "primaryKey": "id",
1695                "columns": [
1696                    { "name": "id", "type": "string", "nullable": false },
1697                    { "name": "project_id", "type": "string", "nullable": false }
1698                ],
1699                "scopes": [{ "pattern": "project:{project_id}" }]
1700            }]
1701        });
1702        let path_string = path.to_str().expect("UTF-8 temp path");
1703
1704        {
1705            let mut first = SyncClient::open_path(
1706                "repair-restart-client".to_owned(),
1707                &schema,
1708                ClientLimits::default(),
1709                path_string,
1710            )
1711            .expect("first open");
1712            first
1713                .subscribe(
1714                    "repair-tasks".to_owned(),
1715                    "tasks".to_owned(),
1716                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1717                    None,
1718                )
1719                .expect("subscribe");
1720            first
1721                .mutate(vec![Mutation::Upsert {
1722                    table: "tasks".to_owned(),
1723                    values: Map::from_iter([
1724                        ("id".to_owned(), Value::from("offline-row")),
1725                        ("project_id".to_owned(), Value::from("p1")),
1726                    ]),
1727                    base_version: None,
1728                }])
1729                .expect("queue offline work");
1730            assert_eq!(
1731                first
1732                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1733                        rebootstrap_id: "restart-receipt".to_owned(),
1734                    })
1735                    .expect("first rebootstrap"),
1736                LocalDataRebootstrapResult {
1737                    already_applied: false,
1738                    retained_commits: 1,
1739                    reset_subscriptions: 1,
1740                }
1741            );
1742        }
1743
1744        let mut reopened = SyncClient::open_path(
1745            "repair-restart-client".to_owned(),
1746            &schema,
1747            ClientLimits::default(),
1748            path_string,
1749        )
1750        .expect("reopen");
1751        reopened.drain_change_batches();
1752        reopened.drain_sync_intents();
1753        assert_eq!(
1754            reopened
1755                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1756                    rebootstrap_id: "restart-receipt".to_owned(),
1757                })
1758                .expect("receipt replay"),
1759            LocalDataRebootstrapResult {
1760                already_applied: true,
1761                retained_commits: 1,
1762                reset_subscriptions: 1,
1763            }
1764        );
1765        assert!(reopened.drain_change_batches().is_empty());
1766        assert!(reopened.drain_sync_intents().is_empty());
1767        drop(reopened);
1768        std::fs::remove_file(path).expect("remove temp database");
1769    }
1770
1771    #[test]
1772    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1773        let mut malformed = client();
1774        malformed
1775            .subscribe(
1776                "repair-tasks".to_owned(),
1777                "tasks".to_owned(),
1778                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1779                None,
1780            )
1781            .expect("subscribe");
1782        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1783        malformed.drain_change_batches();
1784        malformed.drain_sync_intents();
1785        let malformed_error = malformed
1786            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1787                rebootstrap_id: "malformed".to_owned(),
1788            })
1789            .expect_err("malformed receipt must fail closed");
1790        assert_eq!(
1791            malformed_error,
1792            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1793        );
1794        assert!(!malformed.upgrading());
1795        assert_eq!(
1796            malformed
1797                .subscription_state("repair-tasks")
1798                .expect("unchanged subscription")
1799                .cursor,
1800            -1
1801        );
1802        assert!(malformed.drain_change_batches().is_empty());
1803        assert!(malformed.drain_sync_intents().is_empty());
1804
1805        let mut unreadable = client();
1806        unreadable
1807            .conn
1808            .execute(
1809                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1810                rusqlite::params!["server-row", "p1"],
1811            )
1812            .expect("seed visible row");
1813        unreadable
1814            .conn
1815            .execute("DROP TABLE _syncular_meta", [])
1816            .expect("break marker storage");
1817        let unreadable_error = unreadable
1818            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1819                rebootstrap_id: "unreadable".to_owned(),
1820            })
1821            .expect_err("unreadable marker storage must fail closed");
1822        assert_eq!(
1823            unreadable_error,
1824            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1825        );
1826        let visible_rows = unreadable
1827            .conn
1828            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1829            .expect("visible projection remains");
1830        assert_eq!(visible_rows, 1);
1831        assert!(!unreadable.upgrading());
1832        assert!(unreadable.drain_change_batches().is_empty());
1833        assert!(unreadable.drain_sync_intents().is_empty());
1834    }
1835
1836    #[test]
1837    fn local_rebootstrap_cannot_bypass_schema_floor() {
1838        let mut client = client();
1839        client.set_schema_floor(Some(SchemaFloor {
1840            required_schema_version: Some(2),
1841            latest_schema_version: Some(2),
1842        }));
1843        let error = client
1844            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1845                rebootstrap_id: "blocked-floor".to_owned(),
1846            })
1847            .expect_err("schema floor must block repair");
1848        assert!(error.contains("cannot bypass an active schema-floor stop"));
1849    }
1850
1851    #[test]
1852    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1853        let mut client = client();
1854        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1855        const COMMIT_COUNT: usize = 32;
1856
1857        for index in 0..COMMIT_COUNT {
1858            client
1859                .mutate(vec![Mutation::Upsert {
1860                    table: "tasks".to_owned(),
1861                    values: Map::from_iter([
1862                        ("id".to_owned(), Value::from(format!("task-{index}"))),
1863                        ("project_id".to_owned(), Value::from("project-1")),
1864                    ]),
1865                    base_version: None,
1866                }])
1867                .expect("queue commit");
1868        }
1869
1870        let (_, request_meta) = client.build_request(false);
1871        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1872        let mut frames = vec![Frame::RespHeader {
1873            required_schema_version: None,
1874            latest_schema_version: None,
1875            log_epoch: Some("epoch-1".to_owned()),
1876            reset_required: Some(false),
1877        }];
1878        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1879            |(index, client_commit_id)| Frame::PushResult {
1880                client_commit_id: client_commit_id.clone(),
1881                status: PushStatus::Applied,
1882                commit_seq: Some(index as i64 + 1),
1883                results: vec![OpResult::Applied { op_index: 0 }],
1884            },
1885        ));
1886        let response = Message {
1887            wire_version: WIRE_VERSION,
1888            msg_kind: MsgKind::Response,
1889            frames,
1890        };
1891        let mut transport =
1892            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1893
1894        client.overlay_rebuild_count.set(0);
1895        client.outcome_prune_count.set(0);
1896        let outcome = client.process_response(&mut transport, response, &request_meta);
1897        assert!(matches!(outcome, SyncOutcome::Ok(_)));
1898        assert!(client.pending_commit_ids().is_empty());
1899        assert_eq!(
1900            client.overlay_rebuild_count.get(),
1901            1,
1902            "one response must reconcile its acknowledged commits with one overlay rebuild"
1903        );
1904        assert_eq!(
1905            client.outcome_prune_count.get(),
1906            1,
1907            "one response must enforce outcome retention once"
1908        );
1909    }
1910
1911    #[test]
1912    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1913        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1914            "columns": [
1915                {"name":"id", "type":"string", "nullable":false},
1916                {"name":"project_id", "type":"string", "nullable":false},
1917                {"name":"title", "type":"string", "nullable":false}
1918            ], "scopes": [{"pattern":"project:{project_id}"}],
1919            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1920            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1921        }] });
1922        let mut incremental =
1923            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1924        let mut reference =
1925            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1926        let tables = vec!["tasks".to_owned()];
1927        for index in 0..32 {
1928            let id = format!("task-{}", index % 8);
1929            let values = Map::from_iter([
1930                ("id".to_owned(), Value::from(id.clone())),
1931                (
1932                    "project_id".to_owned(),
1933                    Value::from(format!("p{}", index % 2)),
1934                ),
1935                ("title".to_owned(), Value::from(format!("needle{index}"))),
1936            ]);
1937            let payload = encode_row_json(
1938                incremental.schema.table("tasks").unwrap(),
1939                &id,
1940                &values,
1941                &incremental.encryption,
1942            )
1943            .unwrap();
1944            let change = ssp2::model::Change {
1945                table_index: 0,
1946                row_id: id,
1947                op: if index % 5 == 0 {
1948                    Op::Delete
1949                } else {
1950                    Op::Upsert
1951                },
1952                row_version: Some(index + 1),
1953                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1954                row: Some(payload),
1955            };
1956            reference.overlay_dirty.set(true);
1957            for instance in [&mut incremental, &mut reference] {
1958                instance.begin_observation("mirror_test").unwrap();
1959                let mut batch = ChangeAccumulator::default();
1960                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1961                instance
1962                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
1963                    .unwrap();
1964                instance.rebuild_overlay_if_dirty();
1965                instance.finish_observation("mirror_test", batch).unwrap();
1966            }
1967            for sql in [
1968                "SELECT * FROM tasks ORDER BY id",
1969                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1970            ] {
1971                assert_eq!(
1972                    incremental.query(sql, &[]).unwrap(),
1973                    reference.query(sql, &[]).unwrap()
1974                );
1975            }
1976            assert_eq!(incremental.local_revision(), reference.local_revision());
1977            assert_eq!(
1978                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1979                serde_json::to_value(reference.drain_change_batches()).unwrap()
1980            );
1981        }
1982        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1983        assert_eq!(reference.overlay_rebuild_count.get(), 32);
1984    }
1985
1986    #[test]
1987    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1988        let cases = [
1989            (
1990                "string",
1991                vec![
1992                    json!("1"),
1993                    json!("01"),
1994                    json!(""),
1995                    json!("nul\u{0}id"),
1996                    json!("é"),
1997                ],
1998            ),
1999            (
2000                "integer",
2001                vec![
2002                    json!(i64::MIN),
2003                    json!(-1),
2004                    json!(0),
2005                    json!(1),
2006                    json!(i64::MAX),
2007                ],
2008            ),
2009            ("boolean", vec![json!(false), json!(true)]),
2010            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2011        ];
2012        for (kind, ids) in cases {
2013            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2014                "columns":[{"name":"id","type":kind,"nullable":false},
2015                    {"name":"project_id","type":"string","nullable":false}],
2016                "scopes":[{"pattern":"project:{project_id}"}]}]});
2017            let mut client =
2018                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2019            for (index, id) in ids.iter().enumerate() {
2020                let table = client.schema.table("tasks").unwrap();
2021                client
2022                    .write_base_row(
2023                        "tasks",
2024                        &vec![
2025                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2026                            Some(ColumnValue::String(format!("p{index}"))),
2027                        ],
2028                        1,
2029                    )
2030                    .unwrap();
2031            }
2032            client.rebuild_overlay_if_dirty();
2033            for base in [true, false] {
2034                let full = if base {
2035                    base_table("tasks")
2036                } else {
2037                    visible_table("tasks")
2038                };
2039                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2040                for row_id in [
2041                    "1",
2042                    "01",
2043                    "1.0",
2044                    "1.0000000000000002",
2045                    "+1",
2046                    "1e0",
2047                    " 1",
2048                    "-1",
2049                    "0",
2050                    "9223372036854775807",
2051                    "-9223372036854775808",
2052                    "true",
2053                    "false",
2054                    "",
2055                    "nul\u{0}id",
2056                    "é",
2057                    "null",
2058                    "{\"a\":1}",
2059                ] {
2060                    let old_sql =
2061                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2062                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2063                    let old = client
2064                        .conn
2065                        .prepare(&old_sql)
2066                        .unwrap()
2067                        .query_map([row_id], |row| row.get::<_, String>(0))
2068                        .unwrap()
2069                        .collect::<Result<Vec<_>, _>>()
2070                        .unwrap();
2071                    let new = client
2072                        .conn
2073                        .prepare_cached(&new_sql)
2074                        .unwrap()
2075                        .query_map([row_id], |row| row.get::<_, String>(0))
2076                        .unwrap()
2077                        .collect::<Result<Vec<_>, _>>()
2078                        .unwrap();
2079                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2080                    let mut batch = ChangeAccumulator::default();
2081                    assert_eq!(
2082                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
2083                        !old.is_empty()
2084                    );
2085                    let expected = old.first().map(|scope| {
2086                        BTreeMap::from([(
2087                            "tasks".to_owned(),
2088                            Some(BTreeSet::from([format!("project:{scope}")])),
2089                        )])
2090                    });
2091                    assert_eq!(batch.tables, expected.unwrap_or_default());
2092                    client
2093                        .conn
2094                        .execute_batch("SAVEPOINT lookup_delete")
2095                        .unwrap();
2096                    if base {
2097                        client.delete_base_row("tasks", row_id).unwrap();
2098                    } else {
2099                        client.apply_outbox_ops(&[OutboxOp {
2100                            upsert: false,
2101                            table: "tasks".into(),
2102                            row_id: row_id.into(),
2103                            base_version: None,
2104                            values: None,
2105                        }]);
2106                    }
2107                    let remaining: i64 = client
2108                        .conn
2109                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2110                            row.get(0)
2111                        })
2112                        .unwrap();
2113                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2114                    client
2115                        .conn
2116                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2117                        .unwrap();
2118                    client.overlay_dirty.set(false);
2119                }
2120                for (predicate, expected) in [
2121                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2122                    (predicate, "SEARCH"),
2123                ] {
2124                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2125                    let details = client
2126                        .conn
2127                        .prepare(&sql)
2128                        .unwrap()
2129                        .query_map(["1"], |row| row.get::<_, String>(3))
2130                        .unwrap()
2131                        .collect::<Result<Vec<_>, _>>()
2132                        .unwrap();
2133                    assert!(
2134                        details.iter().any(|detail| detail.contains(expected)),
2135                        "{kind}: {details:?}"
2136                    );
2137                }
2138            }
2139        }
2140    }
2141
2142    #[test]
2143    fn primary_keys_reject_every_ineligible_column_type() {
2144        // §2.4: a `rowId` is a string, so a primary key type whose string form
2145        // differs per renderer, or cannot be reproduced by local storage
2146        // comparison, is a schema error rather than a silent lookup miss.
2147        for ty in ["float", "bytes", "crdt", "blob_ref"] {
2148            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2149                "columns":[{"name":"id","type":ty,"nullable":false},
2150                    {"name":"project_id","type":"string","nullable":false}],
2151                "scopes":[{"pattern":"project:{project_id}"}]}]});
2152            let error =
2153                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2154                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2155                    Err(error) => error,
2156                };
2157            assert!(
2158                error.contains("has an ineligible column type (§2.4)"),
2159                "{ty}: {error}"
2160            );
2161        }
2162        for ty in ["string", "integer", "boolean", "json"] {
2163            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2164                "columns":[{"name":"id","type":ty,"nullable":false},
2165                    {"name":"project_id","type":"string","nullable":false}],
2166                "scopes":[{"pattern":"project:{project_id}"}]}]});
2167            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2168                .unwrap_or_else(|error| panic!("{ty}: {error}"));
2169        }
2170    }
2171
2172    #[test]
2173    fn pending_row_reconciliation_matches_full_fifo_replay() {
2174        for unique in [false, true] {
2175            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2176                "name": name, "primaryKey": "id", "columns": [
2177                    {"name":"id", "type":"string", "nullable":false},
2178                    {"name":"project_id", "type":"string", "nullable":false},
2179                    {"name":"title", "type":"string", "nullable":false}],
2180                "scopes": [{"pattern":"project:{project_id}"}],
2181                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2182                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2183            })))});
2184            let mut incremental =
2185                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2186            let mut reference =
2187                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2188            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2189            for instance in [&mut incremental, &mut reference] {
2190                for table in &tables {
2191                    for index in 0..12 {
2192                        instance
2193                            .write_base_row(
2194                                table,
2195                                &vec![
2196                                    Some(ColumnValue::String(format!("row{index}"))),
2197                                    Some(ColumnValue::String("p0".into())),
2198                                    Some(ColumnValue::String(format!("base{index}"))),
2199                                ],
2200                                1,
2201                            )
2202                            .unwrap();
2203                    }
2204                }
2205                instance.rebuild_overlay_if_dirty();
2206            }
2207            for index in 0..80 {
2208                let mutations = (0..3)
2209                    .map(|offset| {
2210                        let table = tables[(index + offset) % 2].clone();
2211                        let id = format!("row{}", (index + offset) % 16);
2212                        if (index + offset) % 7 == 0 {
2213                            Mutation::Delete {
2214                                table,
2215                                row_id: id,
2216                                base_version: None,
2217                            }
2218                        } else {
2219                            Mutation::Upsert {
2220                                table,
2221                                values: Map::from_iter([
2222                                    ("id".into(), Value::from(id)),
2223                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
2224                                    (
2225                                        "title".into(),
2226                                        Value::from(format!("needle{}", (index + offset) % 16)),
2227                                    ),
2228                                ]),
2229                                base_version: None,
2230                            }
2231                        }
2232                    })
2233                    .collect::<Vec<_>>();
2234                for instance in [&mut incremental, &mut reference] {
2235                    instance.mutate(mutations.clone()).unwrap();
2236                }
2237                // Most frames touch only the table without unique constraints.
2238                // Mixed frames must retain full replay for both tables.
2239                let changes = (0..3)
2240                    .map(|offset| {
2241                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2242                        let id = format!("row{}", (index + offset) % 16);
2243                        let values = Map::from_iter([
2244                            ("id".into(), Value::from(id.clone())),
2245                            (
2246                                "project_id".into(),
2247                                Value::from(format!("p{}", (index + 1) % 3)),
2248                            ),
2249                            (
2250                                "title".into(),
2251                                Value::from(format!("remote{index}_{offset}")),
2252                            ),
2253                        ]);
2254                        ssp2::model::Change {
2255                            table_index: table_index as u16,
2256                            row_id: id.clone(),
2257                            op: if (index + offset) % 5 == 0 {
2258                                Op::Delete
2259                            } else {
2260                                Op::Upsert
2261                            },
2262                            row_version: Some(index as i64 + 2),
2263                            scopes: vec![],
2264                            row: Some(
2265                                encode_row_json(
2266                                    incremental.schema.table(&tables[table_index]).unwrap(),
2267                                    &id,
2268                                    &values,
2269                                    &incremental.encryption,
2270                                )
2271                                .unwrap(),
2272                            ),
2273                        }
2274                    })
2275                    .collect::<Vec<_>>();
2276                reference.overlay_dirty.set(true);
2277                incremental.apply_commit_frame(&tables, &changes).unwrap();
2278                reference.apply_commit_frame(&tables, &changes).unwrap();
2279                for table in &tables {
2280                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2281                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2282                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2283                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2284                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2285                    }
2286                }
2287                assert_eq!(incremental.local_revision(), reference.local_revision());
2288                assert_eq!(
2289                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2290                    serde_json::to_value(reference.drain_change_batches()).unwrap()
2291                );
2292            }
2293            assert_eq!(
2294                incremental.overlay_rebuild_count.get(),
2295                if unique { 81 } else { 21 }
2296            );
2297            assert_eq!(reference.overlay_rebuild_count.get(), 81);
2298        }
2299    }
2300
2301    #[test]
2302    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2303        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2304            "columns":[{"name":"id", "type":"string", "nullable":false},
2305                {"name":"title", "type":"string", "nullable":false}],
2306            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2307        let mut client =
2308            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2309        for (id, title) in [("a", "original"), ("b", "occupied")] {
2310            client
2311                .write_base_row(
2312                    "tasks",
2313                    &vec![
2314                        Some(ColumnValue::String(id.into())),
2315                        Some(ColumnValue::String(title.into())),
2316                    ],
2317                    1,
2318                )
2319                .unwrap();
2320        }
2321        client.rebuild_overlay_if_dirty();
2322        let pending = client
2323            .mutate(vec![Mutation::Upsert {
2324                table: "tasks".into(),
2325                values: Map::from_iter([
2326                    ("id".into(), Value::from("a")),
2327                    ("title".into(), Value::from("available")),
2328                ]),
2329                base_version: None,
2330            }])
2331            .unwrap();
2332        assert_eq!(
2333            client
2334                .conn
2335                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2336                    .get::<_, String>(
2337                    0
2338                ))
2339                .unwrap(),
2340            "available"
2341        );
2342        for (title, expected) in [("available", "original"), ("occupied", "available")] {
2343            let change = ssp2::model::Change {
2344                table_index: 0,
2345                row_id: "b".into(),
2346                op: Op::Upsert,
2347                row_version: Some(2),
2348                scopes: vec![],
2349                row: Some(
2350                    encode_row_json(
2351                        client.schema.table("tasks").unwrap(),
2352                        "b",
2353                        &Map::from_iter([
2354                            ("id".into(), Value::from("b")),
2355                            ("title".into(), Value::from(title)),
2356                        ]),
2357                        &client.encryption,
2358                    )
2359                    .unwrap(),
2360                ),
2361            };
2362            client
2363                .apply_commit_frame(&["tasks".into()], &[change])
2364                .unwrap();
2365            assert_eq!(
2366                client
2367                    .conn
2368                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2369                        .get::<_, String>(
2370                        0
2371                    ))
2372                    .unwrap(),
2373                expected
2374            );
2375            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2376        }
2377        assert_eq!(client.overlay_rebuild_count.get(), 3);
2378    }
2379
2380    #[test]
2381    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2382        for failure in ["visible", "revision", "commit"] {
2383            let mut client = client();
2384            let id = client
2385                .mutate(vec![Mutation::Upsert {
2386                    table: "tasks".into(),
2387                    values: Map::from_iter([
2388                        ("id".into(), Value::from("t1")),
2389                        ("project_id".into(), Value::from("pending")),
2390                    ]),
2391                    base_version: None,
2392                }])
2393                .unwrap();
2394            client.drain_change_batches();
2395            let revision = client.local_revision();
2396            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2397            let trigger = match failure {
2398                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2399                "revision" => format!("CREATE TRIGGER fail_pending BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(ABORT, 'injected revision failure'); END"),
2400                _ => "PRAGMA foreign_keys = ON; CREATE TABLE pending_parent(id INTEGER PRIMARY KEY); CREATE TABLE pending_child(id INTEGER REFERENCES pending_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_pending AFTER INSERT ON _syncular_base_tasks BEGIN INSERT INTO pending_child VALUES(1); END".to_owned(),
2401            };
2402            client.conn.execute_batch(&trigger).unwrap();
2403            let change = ssp2::model::Change {
2404                table_index: 0,
2405                row_id: "t1".into(),
2406                op: Op::Upsert,
2407                row_version: Some(2),
2408                scopes: vec![],
2409                row: Some(
2410                    encode_row_json(
2411                        client.schema.table("tasks").unwrap(),
2412                        "t1",
2413                        &Map::from_iter([
2414                            ("id".into(), Value::from("t1")),
2415                            ("project_id".into(), Value::from("remote")),
2416                        ]),
2417                        &client.encryption,
2418                    )
2419                    .unwrap(),
2420                ),
2421            };
2422            assert!(client
2423                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2424                .is_err());
2425            assert!(client.conn.is_autocommit());
2426            assert!(!client.overlay_dirty.get());
2427            assert_eq!(client.local_revision(), revision);
2428            assert!(client.drain_change_batches().is_empty());
2429            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2430            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2431            assert!(client
2432                .query("SELECT * FROM _syncular_base_tasks", &[])
2433                .unwrap()
2434                .is_empty());
2435            client
2436                .conn
2437                .execute_batch("DROP TRIGGER fail_pending")
2438                .unwrap();
2439            client
2440                .apply_commit_frame(&["tasks".into()], &[change])
2441                .unwrap();
2442            assert_eq!(client.local_revision(), revision + 1);
2443            assert_eq!(client.pending_commit_ids(), vec![id]);
2444            // The overlay keeps the pending write visible; the row now carries
2445            // the server row's version it is layered on (§7.1).
2446            let mut expected = before[0].clone();
2447            expected.insert("_syncular_version".to_owned(), Value::from(2));
2448            assert_eq!(
2449                client.query("SELECT * FROM tasks", &[]).unwrap(),
2450                vec![expected]
2451            );
2452            assert_eq!(
2453                client
2454                    .conn
2455                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2456                        .get::<_, String>(0))
2457                    .unwrap(),
2458                "remote"
2459            );
2460        }
2461    }
2462
2463    #[test]
2464    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2465        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2466            "columns": [
2467                {"name":"id", "type":"string", "nullable":false},
2468                {"name":"project_id", "type":"string", "nullable":false},
2469                {"name":"title", "type":"string", "nullable":false}],
2470            "scopes": [{"pattern":"project:{project_id}"}],
2471            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2472            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2473        let mut incremental =
2474            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2475        let mut reference =
2476            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2477        for instance in [&mut incremental, &mut reference] {
2478            for index in 0..16 {
2479                instance
2480                    .write_base_row(
2481                        "tasks",
2482                        &vec![
2483                            Some(ColumnValue::String(format!("task{index}"))),
2484                            Some(ColumnValue::String("p0".to_owned())),
2485                            Some(ColumnValue::String(format!("base{index}"))),
2486                        ],
2487                        1,
2488                    )
2489                    .unwrap();
2490            }
2491            instance.rebuild_overlay_if_dirty();
2492            instance.overlay_rebuild_count.set(0);
2493        }
2494        for index in 0..100 {
2495            let mutations = (0..1 + index % 4)
2496                .map(|offset| {
2497                    let id = format!("task{}", (index + offset) % 24);
2498                    if (index + offset) % 7 == 0 {
2499                        Mutation::Delete {
2500                            table: "tasks".to_owned(),
2501                            row_id: id,
2502                            base_version: None,
2503                        }
2504                    } else {
2505                        Mutation::Upsert {
2506                            table: "tasks".to_owned(),
2507                            values: Map::from_iter([
2508                                ("id".to_owned(), Value::from(id)),
2509                                (
2510                                    "project_id".to_owned(),
2511                                    Value::from(format!("p{}", index % 2)),
2512                                ),
2513                                (
2514                                    "title".to_owned(),
2515                                    Value::from(format!("needle{}", (index + offset) % 24)),
2516                                ),
2517                            ]),
2518                            base_version: None,
2519                        }
2520                    }
2521                })
2522                .collect::<Vec<_>>();
2523            reference.overlay_dirty.set(true);
2524            incremental.mutate(mutations.clone()).unwrap();
2525            reference.mutate(mutations).unwrap();
2526            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2527                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2528                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2529            }
2530            assert_eq!(incremental.local_revision(), reference.local_revision());
2531            assert_eq!(
2532                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2533                serde_json::to_value(reference.drain_change_batches()).unwrap()
2534            );
2535        }
2536        assert_eq!(incremental.outbox.len(), 100);
2537        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2538        assert_eq!(reference.overlay_rebuild_count.get(), 100);
2539    }
2540
2541    #[test]
2542    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2543        for fail_at in ["outbox", "revision"] {
2544            let mut client = client();
2545            let values = Map::from_iter([
2546                ("id".to_owned(), Value::from("t1")),
2547                ("project_id".to_owned(), Value::from("p1")),
2548            ]);
2549            let mutation = Mutation::Upsert {
2550                table: "tasks".to_owned(),
2551                values,
2552                base_version: None,
2553            };
2554            let trigger = if fail_at == "outbox" {
2555                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2556            } else {
2557                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2558            };
2559            client.conn.execute_batch(&trigger).unwrap();
2560            let revision = client.local_revision();
2561            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2562            assert_eq!(client.local_revision(), revision);
2563            assert!(client.outbox.is_empty());
2564            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2565            assert_eq!(
2566                client
2567                    .conn
2568                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2569                        .get::<_, i64>(0))
2570                    .unwrap(),
2571                0
2572            );
2573            assert!(client.drain_change_batches().is_empty());
2574            assert!(!client.overlay_dirty.get());
2575            client
2576                .conn
2577                .execute_batch("DROP TRIGGER fail_append")
2578                .unwrap();
2579            client.mutate(vec![mutation]).unwrap();
2580            assert_eq!(client.outbox.len(), 1);
2581            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2582        }
2583    }
2584
2585    #[test]
2586    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2587        let mut client = client();
2588        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2589        let values = Map::from_iter([
2590            ("id".to_owned(), Value::from("t1")),
2591            ("project_id".to_owned(), Value::from("p1")),
2592        ]);
2593        let payload = encode_row_json(
2594            client.schema.table("tasks").unwrap(),
2595            "t1",
2596            &values,
2597            &client.encryption,
2598        )
2599        .unwrap();
2600        let first = ssp2::model::Change {
2601            table_index: 0,
2602            row_id: "t1".to_owned(),
2603            op: Op::Upsert,
2604            row_version: Some(1),
2605            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2606            row: Some(payload),
2607        };
2608        let mut invalid = first.clone();
2609        invalid.table_index = 1;
2610        let revision = client.local_revision();
2611        client.begin_observation("failed_mirror").unwrap();
2612        assert!(client
2613            .apply_commit_changes(&tables, &[first, invalid])
2614            .is_err());
2615        client.rollback_observation("failed_mirror");
2616        client.rebuild_overlay_if_dirty();
2617        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2618        assert_eq!(
2619            client
2620                .conn
2621                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2622                    .get::<_, i64>(
2623                    0
2624                ))
2625                .unwrap(),
2626            0
2627        );
2628        assert_eq!(client.local_revision(), revision);
2629        assert!(client.drain_change_batches().is_empty());
2630    }
2631
2632    #[test]
2633    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2634        for failure in ["journal", "revision", "commit", "later-frame"] {
2635            let mut client = client();
2636            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2637            for id in ["first", "second"] {
2638                client
2639                    .mutate(vec![Mutation::Upsert {
2640                        table: "tasks".to_owned(),
2641                        values: Map::from_iter([
2642                            ("id".to_owned(), Value::from(id)),
2643                            ("project_id".to_owned(), Value::from("p1")),
2644                        ]),
2645                        base_version: None,
2646                    }])
2647                    .unwrap();
2648            }
2649            let (_, meta) = client.build_request(false);
2650            // A malicious or stale response must not drain an unsent local commit.
2651            client
2652                .mutate(vec![Mutation::Upsert {
2653                    table: "tasks".to_owned(),
2654                    values: Map::from_iter([
2655                        ("id".to_owned(), Value::from("later")),
2656                        ("project_id".to_owned(), Value::from("p1")),
2657                    ]),
2658                    base_version: None,
2659                }])
2660                .unwrap();
2661            let ids = client.pending_commit_ids();
2662            let rows = client
2663                .query("SELECT * FROM tasks ORDER BY id", &[])
2664                .unwrap();
2665            let revision = client.local_revision();
2666            client.drain_change_batches();
2667            if failure != "later-frame" {
2668                client.conn.execute_batch(match failure {
2669                    "journal" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 1 BEGIN SELECT RAISE(FAIL, 'injected second journal failure'); END",
2670                    "revision" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN SELECT RAISE(FAIL, 'injected run revision failure'); END",
2671                    _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_run AFTER INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN INSERT INTO ack_child VALUES (1); END",
2672                }).unwrap();
2673            }
2674            let mut response = Message {
2675                wire_version: WIRE_VERSION,
2676                msg_kind: MsgKind::Response,
2677                frames: vec![Frame::RespHeader {
2678                    required_schema_version: None,
2679                    latest_schema_version: None,
2680                    log_epoch: Some("epoch-1".to_owned()),
2681                    reset_required: Some(false),
2682                }],
2683            };
2684            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2685                response.frames.push(Frame::PushResult {
2686                    client_commit_id: id.to_owned(),
2687                    status: PushStatus::Applied,
2688                    commit_seq: Some(1),
2689                    results: vec![OpResult::Applied { op_index: 0 }],
2690                });
2691            }
2692            if failure == "later-frame" {
2693                response.frames.push(Frame::Error {
2694                    code: "sync.invalid_request".to_owned(),
2695                    message: "later error".to_owned(),
2696                    category: "protocol".to_owned(),
2697                    retryable: false,
2698                    recommended_action: "retry".to_owned(),
2699                    details: None,
2700                });
2701            }
2702            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2703            let outcome = client.process_response(&mut transport, response.clone(), &meta);
2704            assert!(
2705                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2706            );
2707            if failure == "later-frame" {
2708                assert_eq!(client.pending_commit_ids(), ids[2..]);
2709                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2710                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2711                let batches = client.drain_change_batches();
2712                assert_eq!(batches.len(), 1);
2713                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2714                assert_eq!(client.local_revision(), revision + 1);
2715                response.frames.pop();
2716            } else {
2717                assert_eq!(client.pending_commit_ids(), ids);
2718                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2719                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2720                assert_eq!(client.local_revision(), revision);
2721                assert!(client.drain_change_batches().is_empty());
2722                assert_eq!(
2723                    client
2724                        .query("SELECT * FROM tasks ORDER BY id", &[])
2725                        .unwrap(),
2726                    rows
2727                );
2728                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2729            }
2730            for frame in &mut response.frames {
2731                if let Frame::PushResult { status, .. } = frame {
2732                    *status = PushStatus::Cached;
2733                }
2734            }
2735            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2736            else {
2737                panic!("retry failed");
2738            };
2739            assert_eq!(
2740                report.applied,
2741                if failure == "later-frame" {
2742                    vec![]
2743                } else {
2744                    ids[..2].to_vec()
2745                }
2746            );
2747            assert_eq!(client.pending_commit_ids(), ids[2..]);
2748            let batches = client.drain_change_batches();
2749            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2750            if let Some(batch) = batches.first() {
2751                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2752            }
2753            assert_eq!(client.local_revision(), revision + 1);
2754        }
2755    }
2756
2757    #[test]
2758    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2759        for failure in ["revision", "journal", "commit"] {
2760            for status in ["applied", "cached", "rejected", "conflict"] {
2761                let mut client = client();
2762                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2763                for id in ["first", "later"] {
2764                    client
2765                        .mutate(vec![Mutation::Upsert {
2766                            table: "tasks".to_owned(),
2767                            values: Map::from_iter([
2768                                ("id".to_owned(), Value::from(id)),
2769                                ("project_id".to_owned(), Value::from("p1")),
2770                            ]),
2771                            base_version: None,
2772                        }])
2773                        .unwrap();
2774                }
2775                let (_, meta) = client.build_request(false);
2776                let ids = client.pending_commit_ids();
2777                let rows = client
2778                    .query("SELECT * FROM tasks ORDER BY id", &[])
2779                    .unwrap();
2780                let revision = client.local_revision();
2781                client.drain_change_batches();
2782                let (push_status, results) = match status {
2783                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2784                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2785                    "conflict" => (
2786                        PushStatus::Rejected,
2787                        vec![OpResult::Conflict {
2788                            op_index: 0,
2789                            code: "sync.version_conflict".to_owned(),
2790                            message: "conflict".to_owned(),
2791                            server_version: 1,
2792                            server_row: encode_row_json(
2793                                client.schema.table("tasks").unwrap(),
2794                                "first",
2795                                &Map::from_iter([
2796                                    ("id".to_owned(), Value::from("first")),
2797                                    ("project_id".to_owned(), Value::from("p1")),
2798                                ]),
2799                                &client.encryption,
2800                            )
2801                            .unwrap(),
2802                            conflict_columns: Vec::new(),
2803                        }],
2804                    ),
2805                    _ => (
2806                        PushStatus::Rejected,
2807                        vec![OpResult::Error {
2808                            op_index: 0,
2809                            code: "sync.validation_failed".to_owned(),
2810                            message: "rejected".to_owned(),
2811                            retryable: false,
2812                        }],
2813                    ),
2814                };
2815                let response = Message {
2816                    wire_version: WIRE_VERSION,
2817                    msg_kind: MsgKind::Response,
2818                    frames: vec![
2819                        Frame::RespHeader {
2820                            required_schema_version: None,
2821                            latest_schema_version: None,
2822                            log_epoch: Some("epoch-1".to_owned()),
2823                            reset_required: Some(false),
2824                        },
2825                        Frame::PushResult {
2826                            client_commit_id: ids[0].clone(),
2827                            status: push_status,
2828                            commit_seq: Some(1),
2829                            results,
2830                        },
2831                    ],
2832                };
2833                client.conn.execute_batch(match failure {
2834                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2835                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2836                    _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_ack AFTER INSERT ON _syncular_commit_outcomes BEGIN INSERT INTO ack_child VALUES (1); END",
2837                }).unwrap();
2838                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2839                let outcome = client.process_response(&mut transport, response.clone(), &meta);
2840                assert_eq!(
2841                    client.pending_commit_ids(),
2842                    ids,
2843                    "{failure}/{status}: pending identity"
2844                );
2845                assert_eq!(
2846                    client.local_revision(),
2847                    revision,
2848                    "{failure}/{status}: revision"
2849                );
2850                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2851                assert!(client.conflicts().is_empty());
2852                assert!(client.rejections().is_empty());
2853                assert!(client.drain_change_batches().is_empty());
2854                assert_eq!(
2855                    client
2856                        .query("SELECT * FROM tasks ORDER BY id", &[])
2857                        .unwrap(),
2858                    rows
2859                );
2860                assert!(
2861                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2862                    "{failure}/{status}: failed round"
2863                );
2864                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2865                assert!(matches!(
2866                    client.process_response(&mut transport, response, &meta),
2867                    SyncOutcome::Ok(_)
2868                ));
2869                assert_eq!(client.pending_commit_ids(), ids[1..]);
2870                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2871                assert_eq!(client.drain_change_batches().len(), 1);
2872            }
2873        }
2874    }
2875
2876    #[test]
2877    fn mixed_push_results_reconcile_and_prune_once_per_response() {
2878        let mut client = client();
2879        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2880        for index in 0..4 {
2881            client
2882                .mutate(vec![Mutation::Upsert {
2883                    table: "tasks".to_owned(),
2884                    values: Map::from_iter([
2885                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2886                        ("project_id".to_owned(), Value::from("project-1")),
2887                    ]),
2888                    base_version: None,
2889                }])
2890                .expect("queue commit");
2891        }
2892
2893        let (_, request_meta) = client.build_request(false);
2894        let ids = &request_meta.pushed_ids;
2895        assert_eq!(ids.len(), 4);
2896        let response = Message {
2897            wire_version: WIRE_VERSION,
2898            msg_kind: MsgKind::Response,
2899            frames: vec![
2900                Frame::RespHeader {
2901                    required_schema_version: None,
2902                    latest_schema_version: None,
2903                    log_epoch: Some("epoch-1".to_owned()),
2904                    reset_required: Some(false),
2905                },
2906                Frame::PushResult {
2907                    client_commit_id: ids[0].clone(),
2908                    status: PushStatus::Applied,
2909                    commit_seq: Some(1),
2910                    results: vec![OpResult::Applied { op_index: 0 }],
2911                },
2912                Frame::PushResult {
2913                    client_commit_id: ids[1].clone(),
2914                    status: PushStatus::Cached,
2915                    commit_seq: Some(2),
2916                    results: vec![OpResult::Applied { op_index: 0 }],
2917                },
2918                Frame::PushResult {
2919                    client_commit_id: ids[2].clone(),
2920                    status: PushStatus::Rejected,
2921                    commit_seq: None,
2922                    results: vec![OpResult::Error {
2923                        op_index: 0,
2924                        code: "sync.validation_failed".to_owned(),
2925                        message: "rejected".to_owned(),
2926                        retryable: false,
2927                    }],
2928                },
2929                Frame::PushResult {
2930                    client_commit_id: ids[3].clone(),
2931                    status: PushStatus::Rejected,
2932                    commit_seq: None,
2933                    results: vec![OpResult::Error {
2934                        op_index: 0,
2935                        code: "sync.idempotency_cache_miss".to_owned(),
2936                        message: "retry".to_owned(),
2937                        retryable: true,
2938                    }],
2939                },
2940            ],
2941        };
2942        let mut transport =
2943            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2944
2945        client.overlay_rebuild_count.set(0);
2946        client.outcome_prune_count.set(0);
2947        let outcome = client.process_response(&mut transport, response, &request_meta);
2948        let SyncOutcome::Ok(report) = outcome else {
2949            panic!("mixed push-result response failed");
2950        };
2951
2952        assert_eq!(report.applied, ids[..2]);
2953        assert_eq!(report.rejected, ids[2..3]);
2954        assert_eq!(report.retryable, ids[3..4]);
2955        assert_eq!(client.pending_commit_ids(), ids[3..4]);
2956        assert_eq!(
2957            client
2958                .query("SELECT id FROM tasks ORDER BY id", &[])
2959                .expect("query visible overlay"),
2960            vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2961        );
2962        assert_eq!(client.overlay_rebuild_count.get(), 1);
2963        assert_eq!(client.outcome_prune_count.get(), 1);
2964    }
2965
2966    #[test]
2967    fn secondary_unique_collision_preserves_existing_synced_row() {
2968        let client = SyncClient::new(
2969            "unique-upsert-test".to_owned(),
2970            &json!({
2971                "version": 1,
2972                "tables": [{
2973                    "name": "tasks",
2974                    "primaryKey": "id",
2975                    "columns": [
2976                        { "name": "id", "type": "string", "nullable": false },
2977                        { "name": "project_id", "type": "string", "nullable": false },
2978                        { "name": "title", "type": "string", "nullable": false }
2979                    ],
2980                    "scopes": [{ "pattern": "project:{project_id}" }],
2981                    "indexes": [{
2982                        "name": "tasks_by_project_title",
2983                        "columns": ["project_id", "title"],
2984                        "unique": true
2985                    }]
2986                }]
2987            }),
2988            ClientLimits::default(),
2989        )
2990        .expect("test client");
2991        let table = client.schema.table("tasks").expect("tasks table");
2992        let sql = client.insert_row_sql(&base_table("tasks"), table);
2993
2994        client
2995            .conn
2996            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2997            .expect("insert first row");
2998        client
2999            .conn
3000            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3001            .expect("update same primary key");
3002        client
3003            .conn
3004            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3005            .expect("insert second row");
3006        assert!(client
3007            .conn
3008            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3009            .is_err());
3010
3011        let rows = client
3012            .conn
3013            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3014            .expect("prepare rows")
3015            .query_map([], |row| {
3016                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3017            })
3018            .expect("query rows")
3019            .collect::<Result<Vec<_>, _>>()
3020            .expect("collect rows");
3021        assert_eq!(
3022            rows,
3023            vec![
3024                ("t1".to_owned(), "updated".to_owned()),
3025                ("t2".to_owned(), "original".to_owned())
3026            ]
3027        );
3028    }
3029
3030    #[test]
3031    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3032        let path = std::env::temp_dir().join(format!(
3033            "syncular-startup-intent-{}.db",
3034            uuid::Uuid::new_v4()
3035        ));
3036        let schema = json!({
3037            "version": 1,
3038            "tables": [{
3039                "name": "tasks",
3040                "primaryKey": "id",
3041                "columns": [
3042                    { "name": "id", "type": "string", "nullable": false },
3043                    { "name": "project_id", "type": "string", "nullable": false }
3044                ],
3045                "scopes": [{ "pattern": "project:{project_id}" }]
3046            }]
3047        });
3048
3049        {
3050            let mut first = SyncClient::open_path_with_identity(
3051                None,
3052                &schema,
3053                ClientLimits::default(),
3054                path.to_str().expect("UTF-8 temp path"),
3055            )
3056            .expect("first open");
3057            first
3058                .set_window(
3059                    &WindowBase {
3060                        table: "tasks".to_owned(),
3061                        variable: "project_id".to_owned(),
3062                        fixed_scopes: Vec::new(),
3063                        params: None,
3064                    },
3065                    &["persisted".to_owned()],
3066                )
3067                .expect("persist window");
3068        }
3069
3070        let mut reopened = SyncClient::open_path_with_identity(
3071            None,
3072            &schema,
3073            ClientLimits::default(),
3074            path.to_str().expect("UTF-8 temp path"),
3075        )
3076        .expect("reopen");
3077        assert!(reopened.sync_needed());
3078        assert!(matches!(
3079            reopened.drain_sync_intents().as_slice(),
3080            [SyncIntent::Interactive]
3081        ));
3082        drop(reopened);
3083        std::fs::remove_file(path).expect("remove temp database");
3084    }
3085
3086    #[test]
3087    fn reopening_preserves_immutable_subscription_identity_and_progress() {
3088        let path = std::env::temp_dir().join(format!(
3089            "syncular-subscription-identity-{}.db",
3090            uuid::Uuid::new_v4()
3091        ));
3092        let schema = json!({
3093            "version": 1,
3094            "tables": [
3095                {
3096                    "name": "tasks",
3097                    "primaryKey": "id",
3098                    "columns": [
3099                        { "name": "id", "type": "string", "nullable": false },
3100                        { "name": "project_id", "type": "string", "nullable": false }
3101                    ],
3102                    "scopes": [{ "pattern": "project:{project_id}" }]
3103                },
3104                {
3105                    "name": "docs",
3106                    "primaryKey": "id",
3107                    "columns": [
3108                        { "name": "id", "type": "string", "nullable": false },
3109                        { "name": "org_id", "type": "string", "nullable": false },
3110                        { "name": "project_id", "type": "string", "nullable": false }
3111                    ],
3112                    "scopes": [
3113                        { "pattern": "org:{org_id}" },
3114                        { "pattern": "project:{projectId}", "column": "project_id" }
3115                    ]
3116                }
3117            ]
3118        });
3119
3120        {
3121            let mut first = SyncClient::open_path_with_identity(
3122                None,
3123                &schema,
3124                ClientLimits::default(),
3125                path.to_str().expect("UTF-8 temp path"),
3126            )
3127            .expect("first open");
3128            first
3129                .subscribe(
3130                    "stable-subscription".to_owned(),
3131                    "tasks".to_owned(),
3132                    vec![(
3133                        "project_id".to_owned(),
3134                        vec!["p2".to_owned(), "p1".to_owned()],
3135                    )],
3136                    Some(r#"{"view":"v1"}"#.to_owned()),
3137                )
3138                .expect("persist subscription");
3139            let persisted = {
3140                let subscription = first
3141                    .subs
3142                    .iter_mut()
3143                    .find(|subscription| subscription.id == "stable-subscription")
3144                    .expect("subscription");
3145                subscription.cursor = 41;
3146                subscription.bootstrap_state = Some("resume-token".to_owned());
3147                subscription.effective = Some(vec![(
3148                    "project_id".to_owned(),
3149                    vec!["p1".to_owned(), "p2".to_owned()],
3150                )]);
3151                subscription.synced_once = true;
3152                subscription.clone()
3153            };
3154            first.persist_sub(&persisted).unwrap();
3155        }
3156
3157        let mut reopened = SyncClient::open_path_with_identity(
3158            None,
3159            &schema,
3160            ClientLimits::default(),
3161            path.to_str().expect("UTF-8 temp path"),
3162        )
3163        .expect("reopen");
3164        let progress = reopened
3165            .subscription_state("stable-subscription")
3166            .expect("persisted state");
3167        assert_eq!(progress.cursor, 41);
3168        assert!(progress.has_resume_token);
3169
3170        reopened
3171            .subscribe(
3172                "stable-subscription".to_owned(),
3173                "tasks".to_owned(),
3174                vec![(
3175                    "project_id".to_owned(),
3176                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3177                )],
3178                Some(r#"{"view":"v1"}"#.to_owned()),
3179            )
3180            .expect("canonical intent is idempotent");
3181        assert_eq!(
3182            reopened
3183                .subscription_state("stable-subscription")
3184                .expect("unchanged state")
3185                .cursor,
3186            progress.cursor
3187        );
3188
3189        for (table, scopes, params) in [
3190            (
3191                "tasks",
3192                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3193                Some(r#"{"view":"v1"}"#.to_owned()),
3194            ),
3195            (
3196                "tasks",
3197                vec![(
3198                    "project_id".to_owned(),
3199                    vec!["p2".to_owned(), "p1".to_owned()],
3200                )],
3201                Some(r#"{"view":"v2"}"#.to_owned()),
3202            ),
3203            (
3204                "docs",
3205                vec![
3206                    ("org_id".to_owned(), vec!["o1".to_owned()]),
3207                    ("projectId".to_owned(), vec!["p1".to_owned()]),
3208                ],
3209                Some(r#"{"view":"v1"}"#.to_owned()),
3210            ),
3211        ] {
3212            let error = reopened
3213                .subscribe(
3214                    "stable-subscription".to_owned(),
3215                    table.to_owned(),
3216                    scopes,
3217                    params,
3218                )
3219                .expect_err("identity rebind must fail");
3220            assert!(error.starts_with("client.subscription_intent_mismatch:"));
3221            assert_eq!(
3222                reopened
3223                    .subscription_state("stable-subscription")
3224                    .expect("unchanged state")
3225                    .cursor,
3226                progress.cursor
3227            );
3228        }
3229
3230        drop(reopened);
3231        std::fs::remove_file(path).expect("remove temp database");
3232    }
3233
3234    #[test]
3235    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3236        let path = std::env::temp_dir().join(format!(
3237            "syncular-satisfied-schema-floor-{}.db",
3238            uuid::Uuid::new_v4()
3239        ));
3240        let schema = json!({
3241            "version": 23,
3242            "tables": [{
3243                "name": "tasks",
3244                "primaryKey": "id",
3245                "columns": [
3246                    { "name": "id", "type": "string", "nullable": false },
3247                    { "name": "project_id", "type": "string", "nullable": false }
3248                ],
3249                "scopes": [{ "pattern": "project:{project_id}" }]
3250            }]
3251        });
3252
3253        {
3254            let mut first = SyncClient::open_path_with_identity(
3255                None,
3256                &schema,
3257                ClientLimits::default(),
3258                path.to_str().expect("UTF-8 temp path"),
3259            )
3260            .expect("first open");
3261            first
3262                .subscribe(
3263                    "tasks".to_owned(),
3264                    "tasks".to_owned(),
3265                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3266                    None,
3267                )
3268                .expect("persist subscription");
3269            first.set_schema_floor(Some(SchemaFloor {
3270                required_schema_version: Some(22),
3271                latest_schema_version: Some(22),
3272            }));
3273        }
3274
3275        let mut reopened = SyncClient::open_path_with_identity(
3276            None,
3277            &schema,
3278            ClientLimits::default(),
3279            path.to_str().expect("UTF-8 temp path"),
3280        )
3281        .expect("reopen");
3282        assert!(reopened.schema_floor().is_none());
3283        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3284        assert!(reopened.sync_needed());
3285        assert!(matches!(
3286            reopened.drain_sync_intents().as_slice(),
3287            [SyncIntent::Interactive]
3288        ));
3289        drop(reopened);
3290        std::fs::remove_file(path).expect("remove temp database");
3291    }
3292
3293    #[test]
3294    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3295        let path = std::env::temp_dir().join(format!(
3296            "syncular-unsatisfied-schema-floor-{}.db",
3297            uuid::Uuid::new_v4()
3298        ));
3299        let schema = json!({
3300            "version": 1,
3301            "tables": [{
3302                "name": "tasks",
3303                "primaryKey": "id",
3304                "columns": [
3305                    { "name": "id", "type": "string", "nullable": false },
3306                    { "name": "project_id", "type": "string", "nullable": false }
3307                ],
3308                "scopes": [{ "pattern": "project:{project_id}" }]
3309            }]
3310        });
3311
3312        {
3313            let mut first = SyncClient::open_path_with_identity(
3314                None,
3315                &schema,
3316                ClientLimits::default(),
3317                path.to_str().expect("UTF-8 temp path"),
3318            )
3319            .expect("first open");
3320            first
3321                .subscribe(
3322                    "tasks".to_owned(),
3323                    "tasks".to_owned(),
3324                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3325                    None,
3326                )
3327                .expect("persist subscription");
3328            first.set_schema_floor(Some(SchemaFloor {
3329                required_schema_version: Some(2),
3330                latest_schema_version: Some(2),
3331            }));
3332        }
3333
3334        let reopened = SyncClient::open_path_with_identity(
3335            None,
3336            &schema,
3337            ClientLimits::default(),
3338            path.to_str().expect("UTF-8 temp path"),
3339        )
3340        .expect("reopen");
3341        assert_eq!(
3342            reopened.schema_floor(),
3343            Some(&SchemaFloor {
3344                required_schema_version: Some(2),
3345                latest_schema_version: Some(2),
3346            })
3347        );
3348        assert!(!reopened.sync_needed());
3349        drop(reopened);
3350        std::fs::remove_file(path).expect("remove temp database");
3351    }
3352
3353    #[test]
3354    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3355        let path = std::env::temp_dir().join(format!(
3356            "syncular-indexed-column-bump-{}.db",
3357            uuid::Uuid::new_v4()
3358        ));
3359        let old_schema = json!({
3360            "version": 1,
3361            "tables": [
3362                {
3363                    "name": "tasks",
3364                    "primaryKey": "id",
3365                    "columns": [
3366                        { "name": "id", "type": "string", "nullable": false },
3367                        { "name": "project_id", "type": "string", "nullable": false }
3368                    ],
3369                    "scopes": [{ "pattern": "project:{project_id}" }]
3370                },
3371                {
3372                    "name": "legacy",
3373                    "primaryKey": "id",
3374                    "columns": [
3375                        { "name": "id", "type": "string", "nullable": false },
3376                        { "name": "project_id", "type": "string", "nullable": false }
3377                    ],
3378                    "scopes": [{ "pattern": "project:{project_id}" }]
3379                }
3380            ]
3381        });
3382        {
3383            let mut first = SyncClient::open_path_with_identity(
3384                None,
3385                &old_schema,
3386                ClientLimits::default(),
3387                path.to_str().expect("UTF-8 temp path"),
3388            )
3389            .expect("open old schema");
3390            first
3391                .subscribe(
3392                    "legacy-sub".to_owned(),
3393                    "legacy".to_owned(),
3394                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3395                    None,
3396                )
3397                .expect("persist legacy subscription");
3398        }
3399
3400        let new_schema = json!({
3401            "version": 2,
3402            "tables": [{
3403                "name": "tasks",
3404                "primaryKey": "id",
3405                "columns": [
3406                    { "name": "id", "type": "string", "nullable": false },
3407                    { "name": "project_id", "type": "string", "nullable": false },
3408                    { "name": "facility_membership_id", "type": "string", "nullable": true }
3409                ],
3410                "scopes": [{ "pattern": "project:{project_id}" }],
3411                "indexes": [{
3412                    "name": "tasks_by_membership",
3413                    "columns": ["project_id", "facility_membership_id"],
3414                    "unique": false
3415                }]
3416            }]
3417        });
3418        let upgraded = SyncClient::open_path_with_identity(
3419            None,
3420            &new_schema,
3421            ClientLimits::default(),
3422            path.to_str().expect("UTF-8 temp path"),
3423        )
3424        .expect("open upgraded schema");
3425        let columns = upgraded
3426            .conn
3427            .prepare("PRAGMA table_info(tasks)")
3428            .expect("prepare columns")
3429            .query_map([], |row| row.get::<_, String>(1))
3430            .expect("query columns")
3431            .collect::<Result<Vec<_>, _>>()
3432            .expect("collect columns");
3433        assert!(columns.contains(&"facility_membership_id".to_owned()));
3434        let index_count: i64 = upgraded
3435            .conn
3436            .query_row(
3437                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3438                [],
3439                |row| row.get(0),
3440            )
3441            .expect("query index");
3442        assert_eq!(index_count, 1);
3443        assert!(upgraded.subscription_state("legacy-sub").is_none());
3444        drop(upgraded);
3445        std::fs::remove_file(path).expect("remove temp database");
3446    }
3447
3448    #[test]
3449    fn migrates_pre_envelope_outcome_journal_additively() {
3450        let path = std::env::temp_dir().join(format!(
3451            "syncular-outcome-migration-{}.db",
3452            uuid::Uuid::new_v4()
3453        ));
3454        let conn = Connection::open(&path).expect("open legacy database");
3455        conn.execute_batch(
3456            "CREATE TABLE _syncular_commit_outcomes (
3457               seq INTEGER PRIMARY KEY AUTOINCREMENT,
3458               client_commit_id TEXT NOT NULL UNIQUE,
3459               status TEXT NOT NULL,
3460               recorded_at_ms INTEGER NOT NULL,
3461               results_json TEXT NOT NULL,
3462               resolution TEXT NOT NULL DEFAULT 'active',
3463               resolved_at_ms INTEGER,
3464               replacement_client_commit_id TEXT);",
3465        )
3466        .expect("create legacy outcome journal");
3467        drop(conn);
3468        let schema = json!({
3469            "version": 1,
3470            "tables": [{
3471                "name": "tasks",
3472                "primaryKey": "id",
3473                "columns": [
3474                    { "name": "id", "type": "string", "nullable": false },
3475                    { "name": "project_id", "type": "string", "nullable": false }
3476                ],
3477                "scopes": [{ "pattern": "project:{project_id}" }]
3478            }]
3479        });
3480        let client = SyncClient::open_path(
3481            "migration-native".to_owned(),
3482            &schema,
3483            ClientLimits::default(),
3484            path.to_str().expect("UTF-8 temp path"),
3485        )
3486        .expect("migrate database");
3487        let has_operations = client
3488            .conn
3489            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3490            .expect("prepare table info")
3491            .query_map([], |row| row.get::<_, String>(1))
3492            .expect("query table info")
3493            .filter_map(Result::ok)
3494            .any(|column| column == "operations_json");
3495        assert!(has_operations);
3496        drop(client);
3497        std::fs::remove_file(path).expect("remove temp database");
3498    }
3499
3500    #[test]
3501    fn retained_rejection_survives_file_reopen_and_security_purge() {
3502        let path =
3503            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
3504        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
3505            { "name": "id", "type": "string", "nullable": false },
3506            { "name": "project_id", "type": "string", "nullable": false },
3507            { "name": "title", "type": "string", "nullable": false }
3508        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
3509        {
3510            let mut first = SyncClient::open_path(
3511                "retained-native".to_owned(),
3512                &schema,
3513                ClientLimits::default(),
3514                path.to_str().unwrap(),
3515            )
3516            .unwrap();
3517            first
3518                .write_base_row(
3519                    "tasks",
3520                    &vec![
3521                        Some(ColumnValue::String("t1".into())),
3522                        Some(ColumnValue::String("p1".into())),
3523                        Some(ColumnValue::String("server".into())),
3524                    ],
3525                    1,
3526                )
3527                .unwrap();
3528            first.rebuild_overlay();
3529            let operation = OutboxOp {
3530                table: "tasks".into(),
3531                row_id: "t1".into(),
3532                upsert: true,
3533                base_version: Some(1),
3534                values: Some(Map::from_iter([
3535                    ("id".into(), json!("t1")),
3536                    ("title".into(), json!("mine")),
3537                ])),
3538            };
3539            first
3540                .retain_failed_commit("retained", std::slice::from_ref(&operation))
3541                .unwrap();
3542            first
3543                .persist_commit_outcome(
3544                    "retained",
3545                    CommitOutcomeStatus::Rejected,
3546                    &[],
3547                    Some(&[operation]),
3548                )
3549                .unwrap();
3550            first.rebuild_overlay();
3551        }
3552        {
3553            let mut reopened = SyncClient::open_path(
3554                "retained-native".to_owned(),
3555                &schema,
3556                ClientLimits::default(),
3557                path.to_str().unwrap(),
3558            )
3559            .unwrap();
3560            let row = reopened
3561                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3562                .unwrap();
3563            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
3564            assert_eq!(
3565                reopened
3566                    .commit_outcome("retained")
3567                    .unwrap()
3568                    .unwrap()
3569                    .retained_rows
3570                    .unwrap()[0]
3571                    .server_row
3572                    .as_ref()
3573                    .unwrap()["title"],
3574                json!("server")
3575            );
3576            reopened
3577                .purge_local_data(&LocalDataPurgeInput {
3578                    purge_id: "protected-purge".into(),
3579                    targets: vec![LocalDataPurgeTarget {
3580                        table: "tasks".into(),
3581                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
3582                    }],
3583                })
3584                .unwrap();
3585            assert!(reopened
3586                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3587                .is_none());
3588            assert!(reopened
3589                .commit_outcome("retained")
3590                .unwrap()
3591                .unwrap()
3592                .retained_rows
3593                .is_none());
3594        }
3595        std::fs::remove_file(path).unwrap();
3596    }
3597
3598    #[test]
3599    fn durable_conflict_outcome_and_resolution_survive_reopen() {
3600        let path = std::env::temp_dir().join(format!(
3601            "syncular-durable-outcome-{}.db",
3602            uuid::Uuid::new_v4()
3603        ));
3604        let schema = json!({
3605            "version": 1,
3606            "tables": [{
3607                "name": "tasks",
3608                "primaryKey": "id",
3609                "columns": [
3610                    { "name": "id", "type": "string", "nullable": false },
3611                    { "name": "project_id", "type": "string", "nullable": false }
3612                ],
3613                "scopes": [{ "pattern": "project:{project_id}" }]
3614            }]
3615        });
3616        let conflict = ConflictRecord {
3617            client_commit_id: "losing-commit".to_owned(),
3618            op_index: 0,
3619            table: "tasks".to_owned(),
3620            row_id: "t1".to_owned(),
3621            code: "sync.version_conflict".to_owned(),
3622            message: "stale base version".to_owned(),
3623            server_version: 2,
3624            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3625            conflict_columns: vec!["project_id".to_owned()],
3626            operation: Some(CommitOperation {
3627                table: "tasks".to_owned(),
3628                row_id: "t1".to_owned(),
3629                op: "upsert".to_owned(),
3630                base_version: Some(1),
3631                values: None,
3632            }),
3633        };
3634        let failed_operations = vec![
3635            OutboxOp {
3636                upsert: true,
3637                table: "tasks".to_owned(),
3638                row_id: "t1".to_owned(),
3639                base_version: Some(1),
3640                values: None,
3641            },
3642            OutboxOp {
3643                upsert: true,
3644                table: "tasks".to_owned(),
3645                row_id: "status-event-1".to_owned(),
3646                base_version: Some(0),
3647                values: None,
3648            },
3649        ];
3650
3651        {
3652            let mut first = SyncClient::open_path(
3653                "durable-native".to_owned(),
3654                &schema,
3655                ClientLimits::default(),
3656                path.to_str().expect("UTF-8 temp path"),
3657            )
3658            .expect("first open");
3659            first
3660                .begin_observation("test_outcome")
3661                .expect("begin outcome");
3662            first
3663                .persist_commit_outcome(
3664                    "losing-commit",
3665                    CommitOutcomeStatus::Conflict,
3666                    &[CommitOperationOutcome::Conflict {
3667                        conflict: conflict.clone(),
3668                    }],
3669                    Some(&failed_operations),
3670                )
3671                .expect("persist outcome");
3672            first.conflicts.push(conflict);
3673            first
3674                .finish_observation(
3675                    "test_outcome",
3676                    ChangeAccumulator {
3677                        conflicts: true,
3678                        outcomes: true,
3679                        ..ChangeAccumulator::default()
3680                    },
3681                )
3682                .expect("commit outcome");
3683        }
3684
3685        {
3686            let mut reopened = SyncClient::open_path(
3687                "durable-native".to_owned(),
3688                &schema,
3689                ClientLimits::default(),
3690                path.to_str().expect("UTF-8 temp path"),
3691            )
3692            .expect("reopen");
3693            assert_eq!(reopened.conflicts().len(), 1);
3694            assert_eq!(
3695                reopened.conflicts()[0].conflict_columns,
3696                vec!["project_id".to_owned()],
3697                "conflictColumns survives the journal round trip"
3698            );
3699            let outcome = reopened
3700                .commit_outcome("losing-commit")
3701                .expect("read outcome")
3702                .expect("outcome");
3703            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3704            let operations = outcome.operations.expect("aggregate envelope");
3705            assert_eq!(operations.len(), 2);
3706            assert_eq!(operations[1].row_id, "status-event-1");
3707            let resolved = reopened
3708                .resolve_commit_outcome(ResolveCommitOutcomeInput {
3709                    client_commit_id: "losing-commit".to_owned(),
3710                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
3711                    replacement_client_commit_id: None,
3712                })
3713                .expect("resolve");
3714            assert_eq!(
3715                resolved.resolution,
3716                CommitOutcomeResolution::ResolvedKeepServer
3717            );
3718            assert!(reopened.conflicts().is_empty());
3719        }
3720
3721        let reopened = SyncClient::open_path(
3722            "durable-native".to_owned(),
3723            &schema,
3724            ClientLimits::default(),
3725            path.to_str().expect("UTF-8 temp path"),
3726        )
3727        .expect("second reopen");
3728        assert!(reopened.conflicts().is_empty());
3729        assert_eq!(
3730            reopened
3731                .commit_outcome("losing-commit")
3732                .expect("read outcome")
3733                .expect("outcome")
3734                .resolution,
3735            CommitOutcomeResolution::ResolvedKeepServer
3736        );
3737        drop(reopened);
3738        std::fs::remove_file(path).expect("remove temp database");
3739    }
3740
3741    #[test]
3742    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3743        let path =
3744            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3745        let schema = json!({
3746            "version": 1,
3747            "tables": [{
3748                "name": "tasks",
3749                "primaryKey": "id",
3750                "columns": [
3751                    { "name": "id", "type": "string", "nullable": false },
3752                    { "name": "project_id", "type": "string", "nullable": false }
3753                ],
3754                "scopes": [{ "pattern": "project:{project_id}" }]
3755            }]
3756        });
3757        let mut client = SyncClient::open_path_with_identity(
3758            Some("sidecar-client".to_owned()),
3759            &schema,
3760            ClientLimits::default(),
3761            path.to_str().expect("UTF-8 temp path"),
3762        )
3763        .expect("open owner");
3764        let base = WindowBase {
3765            table: "tasks".to_owned(),
3766            variable: "project_id".to_owned(),
3767            fixed_scopes: Vec::new(),
3768            params: None,
3769        };
3770        client
3771            .set_window(&base, &["one".to_owned()])
3772            .expect("set window");
3773        client
3774            .mutate(vec![Mutation::Upsert {
3775                table: "tasks".to_owned(),
3776                values: Map::from_iter([
3777                    ("id".to_owned(), Value::from("t1")),
3778                    ("project_id".to_owned(), Value::from("one")),
3779                ]),
3780                base_version: None,
3781            }])
3782            .expect("local mutate");
3783
3784        let coverage = [WindowCoverage {
3785            base,
3786            units: vec!["one".to_owned(), "missing".to_owned()],
3787        }];
3788        let owner = client
3789            .query_snapshot(
3790                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3791                &[],
3792                &coverage,
3793                None,
3794            )
3795            .expect("owner snapshot");
3796        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3797        let sidecar = reader
3798            .query_snapshot(
3799                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3800                &[],
3801                &coverage,
3802            )
3803            .expect("sidecar snapshot");
3804
3805        assert_eq!(sidecar.revision, owner.revision);
3806        assert_eq!(sidecar.rows, owner.rows);
3807        assert_eq!(
3808            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3809            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3810        );
3811        assert_eq!(sidecar.revision, "2");
3812        assert_eq!(sidecar.rows[0]["id"], "t1");
3813        assert_eq!(sidecar.rows[0]["server_version"], -1);
3814        assert!(!sidecar.coverage.complete);
3815        assert_eq!(sidecar.coverage.pending.len(), 1);
3816        assert_eq!(sidecar.coverage.missing.len(), 1);
3817
3818        drop(reader);
3819        drop(client);
3820        std::fs::remove_file(path).expect("remove temp database");
3821    }
3822
3823    #[test]
3824    fn owned_query_failures_classify_order_bound_and_clear() {
3825        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3826            rusqlite::ffi::Error::new(266),
3827            Some("private SQLite prose".to_owned()),
3828        ));
3829        assert_eq!(io.code, Some("client.storage_io"));
3830        assert_eq!(io.sqlite_code, Some(266));
3831        assert_eq!(io.message, "local SQLite storage I/O failed");
3832        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3833            rusqlite::ffi::Error::new(26),
3834            None,
3835        ));
3836        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
3837
3838        let mut client = client();
3839        let generic = QueryReadFailure::from("private query prose".to_owned());
3840        client.record_query_read(
3841            QueryOwner {
3842                id: "first",
3843                tables: &["tasks", "tasks"],
3844            },
3845            Some(&generic),
3846        );
3847        client.record_query_read(
3848            QueryOwner {
3849                id: "second",
3850                tables: &["tasks"],
3851            },
3852            Some(&generic),
3853        );
3854        client.record_query_read(
3855            QueryOwner {
3856                id: "first",
3857                tables: &["docs"],
3858            },
3859            Some(&io),
3860        );
3861        let failures = &client
3862            .diagnostics_snapshot(&Default::default())
3863            .expect("diagnostics")
3864            .query_failures;
3865        assert_eq!(
3866            failures
3867                .iter()
3868                .map(|entry| entry.id.as_str())
3869                .collect::<Vec<_>>(),
3870            vec!["second", "first"]
3871        );
3872        assert_eq!(failures[1].tables, vec!["docs"]);
3873        assert_eq!(failures[1].code, "client.storage_io");
3874
3875        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
3876            client.record_query_read(
3877                QueryOwner {
3878                    id: &format!("query-{index}"),
3879                    tables: &["tasks"],
3880                },
3881                Some(&generic),
3882            );
3883        }
3884        let failures = &client
3885            .diagnostics_snapshot(&Default::default())
3886            .expect("bounded diagnostics")
3887            .query_failures;
3888        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
3889        assert_eq!(failures.last().expect("last failure").id, "query-256");
3890
3891        client
3892            .query_snapshot(
3893                "SELECT id FROM tasks",
3894                &[],
3895                &[],
3896                Some(QueryOwner {
3897                    id: "query-256",
3898                    tables: &["tasks"],
3899                }),
3900            )
3901            .expect("successful owned read");
3902        assert!(client
3903            .diagnostics_snapshot(&Default::default())
3904            .expect("cleared diagnostics")
3905            .query_failures
3906            .iter()
3907            .all(|entry| entry.id != "query-256"));
3908    }
3909
3910    #[test]
3911    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3912        let mut client = client();
3913        let image = Connection::open_in_memory().unwrap();
3914        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3915            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3916            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3917            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3918            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3919        let table = client.schema.table("tasks").unwrap().clone();
3920        let scopes = vec![("project_id".into(), vec!["p1".into()])];
3921        assert!(client
3922            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3923            .is_err());
3924        assert!(client.conn.is_autocommit());
3925        assert_eq!(
3926            client
3927                .query("SELECT count(*) AS n FROM tasks", &[])
3928                .unwrap()[0]["n"],
3929            1024
3930        );
3931        assert_eq!(client.drain_change_batches().len(), 1);
3932        image
3933            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3934            .unwrap();
3935        assert!(matches!(
3936            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3937            Ok(2050)
3938        ));
3939        assert_eq!(
3940            client
3941                .query("SELECT count(*) AS n FROM tasks", &[])
3942                .unwrap()[0]["n"],
3943            2050
3944        );
3945        assert_eq!(client.drain_change_batches().len(), 3);
3946    }
3947
3948    #[test]
3949    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3950        let mut client = client();
3951        let base = WindowBase {
3952            table: "tasks".into(),
3953            variable: "project_id".into(),
3954            fixed_scopes: Vec::new(),
3955            params: None,
3956        };
3957        client
3958            .set_window(&base, &["p1".into(), "p2".into()])
3959            .unwrap();
3960        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3961            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3962            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3963            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3964            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3965        client.drain_change_batches();
3966        assert!(client.set_window(&base, &["p2".into()]).is_err());
3967        assert!(client.conn.is_autocommit());
3968        assert_eq!(
3969            client
3970                .query("SELECT count(*) AS n FROM tasks", &[])
3971                .unwrap()[0]["n"],
3972            1027
3973        );
3974        assert_eq!(client.window_state(&base).units, vec!["p2"]);
3975        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3976        assert_eq!(client.drain_change_batches().len(), 1);
3977        client
3978            .conn
3979            .execute_batch("DROP TRIGGER interrupt_evict")
3980            .unwrap();
3981        client.drain_pending_evictions().unwrap();
3982        assert!(client.load_pending_evictions().unwrap().is_empty());
3983        assert_eq!(
3984            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3985            "held"
3986        );
3987        assert_eq!(client.drain_change_batches().len(), 2);
3988    }
3989
3990    #[test]
3991    fn eviction_marker_failures_roll_back_window_changes() {
3992        let mut client = client();
3993        let base = WindowBase {
3994            table: "tasks".into(),
3995            variable: "project_id".into(),
3996            fixed_scopes: Vec::new(),
3997            params: None,
3998        };
3999        client.set_window(&base, &["p1".into()]).unwrap();
4000        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4001            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4002            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4003            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4004        let revision = client.local_revision();
4005        assert!(client.set_window(&base, &[]).is_err());
4006        assert!(client.conn.is_autocommit());
4007        assert_eq!(client.local_revision(), revision);
4008        assert_eq!(
4009            client
4010                .query("SELECT count(*) AS n FROM tasks", &[])
4011                .unwrap()[0]["n"],
4012            2050
4013        );
4014        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4015        client.conn.execute_batch("DROP TRIGGER fail_marker;
4016            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4017        assert!(client.set_window(&base, &[]).is_err());
4018        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4019            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4020        assert!(client.drain_pending_evictions().is_err());
4021        assert!(client.conn.is_autocommit());
4022        assert_eq!(
4023            client
4024                .query("SELECT count(*) AS n FROM tasks", &[])
4025                .unwrap()[0]["n"],
4026            2
4027        );
4028        assert!(client.set_window(&base, &["p1".into()]).is_err());
4029        assert!(client.conn.is_autocommit());
4030        assert!(client.window_state(&base).units.is_empty());
4031        assert!(client.subs.is_empty());
4032        assert_eq!(
4033            client
4034                .query(
4035                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4036                    &[]
4037                )
4038                .unwrap()[0]["n"],
4039            1
4040        );
4041        client
4042            .conn
4043            .execute_batch("DROP TRIGGER fail_marker")
4044            .unwrap();
4045        client.set_window(&base, &["p1".into()]).unwrap();
4046        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4047    }
4048
4049    #[test]
4050    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4051        let mut client = client();
4052        client
4053            .conn
4054            .execute(
4055                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4056                [],
4057            )
4058            .unwrap();
4059        assert_eq!(
4060            client.drain_pending_evictions().unwrap_err(),
4061            "sync.local_corrupt: invalid pending eviction scopes"
4062        );
4063        assert!(client.conn.is_autocommit());
4064        assert!(client.enqueue_startup_sync_if_needed().is_err());
4065    }
4066
4067    #[test]
4068    fn image_chunks_reject_null_primary_keys() {
4069        let mut client = client();
4070        let image = Connection::open_in_memory().unwrap();
4071        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4072            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4073            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4074            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4075            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4076        let table = client.schema.table("tasks").unwrap().clone();
4077        assert!(client
4078            .apply_sqlite_image(
4079                &image,
4080                &table,
4081                true,
4082                &[("project_id".into(), vec!["p1".into()])],
4083                1025,
4084                7,
4085                "digest"
4086            )
4087            .is_err());
4088        assert!(client.conn.is_autocommit());
4089        assert_eq!(
4090            client
4091                .query("SELECT count(*) AS n FROM tasks", &[])
4092                .unwrap()[0]["n"],
4093            0
4094        );
4095    }
4096
4097    #[test]
4098    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4099        for images in [false, true] {
4100            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4101                "name":name,"primaryKey":"id","columns":[
4102                    {"name":"id","type":"string","nullable":false},
4103                    {"name":"project_id","type":"string","nullable":false},
4104                    {"name":"title","type":"string","nullable":false}],
4105                "scopes":[{"pattern":"project:{project_id}"}],
4106                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4107                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4108            })))});
4109            let mut actual =
4110                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4111            let mut reference =
4112                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4113            let scopes = vec![("project_id".into(), vec!["p1".into()])];
4114            for client in [&mut actual, &mut reference] {
4115                client
4116                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4117                    .unwrap();
4118                client.subs[0].effective = Some(scopes.clone());
4119                for (table, id, title) in [
4120                    ("tasks", "a", "original"),
4121                    ("tasks", "b", "occupied"),
4122                    ("tasks", "c", "deleted"),
4123                    ("notes", "n", "untouched"),
4124                ] {
4125                    client
4126                        .write_base_row(
4127                            table,
4128                            &vec![
4129                                Some(ColumnValue::String(id.into())),
4130                                Some(ColumnValue::String("p1".into())),
4131                                Some(ColumnValue::String(title.into())),
4132                            ],
4133                            1,
4134                        )
4135                        .unwrap();
4136                }
4137                client.rebuild_overlay_if_dirty();
4138                client
4139                    .mutate(vec![
4140                        Mutation::Upsert {
4141                            table: "tasks".into(),
4142                            values: Map::from_iter([
4143                                ("id".into(), Value::from("a")),
4144                                ("project_id".into(), Value::from("p1")),
4145                                ("title".into(), Value::from("available")),
4146                            ]),
4147                            base_version: None,
4148                        },
4149                        Mutation::Delete {
4150                            table: "tasks".into(),
4151                            row_id: "c".into(),
4152                            base_version: None,
4153                        },
4154                        Mutation::Upsert {
4155                            table: "tasks".into(),
4156                            values: Map::from_iter([
4157                                ("id".into(), Value::from("local")),
4158                                ("project_id".into(), Value::from("p1")),
4159                                ("title".into(), Value::from("local value")),
4160                            ]),
4161                            base_version: None,
4162                        },
4163                        Mutation::Upsert {
4164                            table: "notes".into(),
4165                            values: Map::from_iter([
4166                                ("id".into(), Value::from("n")),
4167                                ("project_id".into(), Value::from("p1")),
4168                                ("title".into(), Value::from("pending note")),
4169                            ]),
4170                            base_version: None,
4171                        },
4172                    ])
4173                    .unwrap();
4174            }
4175            let pending = actual.pending_commit_ids();
4176            actual.overlay_rebuild_count.set(0);
4177            for (id, title) in [
4178                ("b", "available"),
4179                ("b", "occupied"),
4180                ("a", "server original"),
4181                ("d", "local value"),
4182                ("d", "released"),
4183                ("c", "redelivered"),
4184            ] {
4185                let table = actual.schema.table("tasks").unwrap().clone();
4186                let values = vec![
4187                    Some(ColumnValue::String(id.into())),
4188                    Some(ColumnValue::String("p1".into())),
4189                    Some(ColumnValue::String(title.into())),
4190                ];
4191                if images {
4192                    let image = Connection::open_in_memory().unwrap();
4193                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4194                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4195                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4196                    image
4197                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4198                        .unwrap();
4199                    assert!(matches!(
4200                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4201                        Ok(1)
4202                    ));
4203                } else {
4204                    let segment = RowsSegment {
4205                        table: "tasks".into(),
4206                        schema_version: 1,
4207                        columns: table.wire_columns.clone(),
4208                        blocks: vec![vec![ssp2::segment::SegmentRow {
4209                            server_version: 2,
4210                            values: values.clone(),
4211                        }]],
4212                    };
4213                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4214                }
4215                reference.write_base_row("tasks", &values, 2).unwrap();
4216                reference.rebuild_overlay();
4217                for table in ["tasks", "notes"] {
4218                    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")] {
4219                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4220                    }
4221                }
4222                assert_eq!(actual.pending_commit_ids(), pending);
4223            }
4224            assert_eq!(
4225                actual.overlay_rebuild_count.get(),
4226                0,
4227                "each chunk must leave unrelated tables alone"
4228            );
4229            let before = actual
4230                .query("SELECT * FROM tasks ORDER BY id", &[])
4231                .unwrap();
4232            let fts_before = actual
4233                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4234                .unwrap();
4235            let revision = actual.local_revision();
4236            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4237            let table = actual.schema.table("tasks").unwrap().clone();
4238            let segment = RowsSegment {
4239                table: "tasks".into(),
4240                schema_version: 1,
4241                columns: table.wire_columns.clone(),
4242                blocks: vec![vec![ssp2::segment::SegmentRow {
4243                    server_version: 3,
4244                    values: vec![
4245                        Some(ColumnValue::String("b".into())),
4246                        Some(ColumnValue::String("p1".into())),
4247                        Some(ColumnValue::String("fresh".into())),
4248                    ],
4249                }]],
4250            };
4251            assert!(actual.apply_segment(0, &segment, false).is_err());
4252            assert!(actual.conn.is_autocommit());
4253            assert_eq!(
4254                actual
4255                    .query("SELECT * FROM tasks ORDER BY id", &[])
4256                    .unwrap(),
4257                before
4258            );
4259            assert_eq!(
4260                actual
4261                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4262                    .unwrap(),
4263                fts_before
4264            );
4265            assert_eq!(actual.local_revision(), revision);
4266            assert_eq!(actual.pending_commit_ids(), pending);
4267        }
4268    }
4269
4270    #[test]
4271    fn import_reconciliation_preserves_typed_pending_primary_keys() {
4272        for (kind, a, b) in [
4273            ("string", json!("a"), json!("b")),
4274            ("integer", json!(1), json!(2)),
4275            ("boolean", json!(true), json!(false)),
4276            ("json", json!("1"), json!("2")),
4277        ] {
4278            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}]}]});
4279            for images in [false, true] {
4280                let mut client =
4281                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4282                let table = client.schema.table("tasks").unwrap().clone();
4283                client
4284                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
4285                    .unwrap();
4286                for (id, title) in [(&a, "original"), (&b, "occupied")] {
4287                    client
4288                        .write_base_row(
4289                            "tasks",
4290                            &vec![
4291                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4292                                Some(ColumnValue::String(title.into())),
4293                            ],
4294                            1,
4295                        )
4296                        .unwrap();
4297                }
4298                client.rebuild_overlay_if_dirty();
4299                client
4300                    .mutate(vec![Mutation::Upsert {
4301                        table: "tasks".into(),
4302                        values: Map::from_iter([
4303                            ("id".into(), a.clone()),
4304                            ("title".into(), json!("available")),
4305                        ]),
4306                        base_version: None,
4307                    }])
4308                    .unwrap();
4309                client.overlay_rebuild_count.set(0);
4310                for (title, expected) in [("available", "original"), ("occupied", "available")] {
4311                    let values = vec![
4312                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4313                        Some(ColumnValue::String(title.into())),
4314                    ];
4315                    if images {
4316                        let image = Connection::open_in_memory().unwrap();
4317                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4318                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4319                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4320                        image
4321                            .execute(
4322                                "INSERT INTO tasks VALUES (?1,?2,2)",
4323                                rusqlite::params![RowParam::Cell(&values[0]), title],
4324                            )
4325                            .unwrap();
4326                        assert!(
4327                            matches!(
4328                                client.apply_sqlite_image(
4329                                    &image,
4330                                    &table,
4331                                    false,
4332                                    &[],
4333                                    1,
4334                                    7,
4335                                    "digest"
4336                                ),
4337                                Ok(1)
4338                            ),
4339                            "{kind}, images={images}"
4340                        );
4341                    } else {
4342                        let segment = RowsSegment {
4343                            table: "tasks".into(),
4344                            schema_version: 1,
4345                            columns: table.wire_columns.clone(),
4346                            blocks: vec![vec![ssp2::segment::SegmentRow {
4347                                server_version: 2,
4348                                values,
4349                            }]],
4350                        };
4351                        assert!(
4352                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
4353                            "{kind}"
4354                        );
4355                    }
4356                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4357                    let actual: String = client
4358                        .conn
4359                        .query_row(
4360                            "SELECT title FROM tasks WHERE id=?1",
4361                            [RowParam::Cell(&key)],
4362                            |row| row.get(0),
4363                        )
4364                        .unwrap();
4365                    assert_eq!(actual, expected, "{kind}, images={images}");
4366                }
4367                assert_eq!(client.overlay_rebuild_count.get(), 0);
4368            }
4369        }
4370    }
4371
4372    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4373        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4374            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4375            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4376            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4377        })))});
4378        let mut client =
4379            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4380        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();
4381        client.rebuild_overlay();
4382        client
4383            .mutate(vec![Mutation::Upsert {
4384                table: "tasks".into(),
4385                values: Map::from_iter([
4386                    ("id".into(), json!("offline")),
4387                    ("project_id".into(), json!("p1")),
4388                    ("title".into(), json!("pending edit")),
4389                ]),
4390                base_version: None,
4391            }])
4392            .unwrap();
4393        let image = Connection::open_in_memory().unwrap();
4394        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4395            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4396        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();
4397        image
4398            .execute(
4399                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4400                [rows],
4401            )
4402            .unwrap();
4403        client.overlay_rebuild_count.set(0);
4404        let table = client.schema.table("tasks").unwrap().clone();
4405        (client, image, table)
4406    }
4407
4408    #[test]
4409    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
4410        let (mut client, image, table) = unique_import_fixture(1);
4411        image
4412            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
4413            .unwrap();
4414        client
4415            .conn
4416            .execute_batch("PRAGMA max_page_count = 1")
4417            .unwrap();
4418        let effective = [("project_id".into(), vec!["p1".into()])];
4419        assert!(client
4420            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
4421            .is_err());
4422        let failure = client.storage_failure.borrow().clone().unwrap();
4423        assert_eq!(failure.code, Some("client.storage_full"));
4424        assert_eq!(failure.sqlite_code, Some(13));
4425        assert!(!failure.message.contains("rollback"));
4426        assert!(client.conn.is_autocommit());
4427        client
4428            .conn
4429            .execute_batch("PRAGMA max_page_count = 1073741823")
4430            .unwrap();
4431        assert!(matches!(
4432            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
4433            Ok(1)
4434        ));
4435        assert!(client.conn.is_autocommit());
4436    }
4437
4438    #[test]
4439    fn large_unique_import_copies_no_unrelated_tables() {
4440        let (mut client, image, table) = unique_import_fixture(10240);
4441        let pending = client.pending_commit_ids();
4442        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4443            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4444        assert!(matches!(
4445            client.apply_sqlite_image(
4446                &image,
4447                &table,
4448                true,
4449                &[("project_id".into(), vec!["p1".into()])],
4450                10240,
4451                7,
4452                "digest"
4453            ),
4454            Ok(10240)
4455        ));
4456        assert_eq!(client.overlay_rebuild_count.get(), 0);
4457        assert_eq!(client.pending_commit_ids(), pending);
4458        assert_eq!(
4459            client
4460                .query("SELECT count(*) AS n FROM tasks", &[])
4461                .unwrap()[0]["n"],
4462            10241
4463        );
4464        assert_eq!(
4465            client
4466                .query("SELECT count(*) AS n FROM tasks_fts", &[])
4467                .unwrap()[0]["n"],
4468            10241
4469        );
4470        assert_eq!(
4471            client
4472                .query("SELECT count(*) AS n FROM notes_fts", &[])
4473                .unwrap()[0]["n"],
4474            8192
4475        );
4476    }
4477
4478    #[test]
4479    #[ignore = "manual scaling benchmark; see bench/README.md"]
4480    fn benchmark_unique_image_import() {
4481        let mut samples = Vec::new();
4482        for rows in [10240, 51200, 102400] {
4483            for trial in 0..4 {
4484                let (mut client, image, table) = unique_import_fixture(rows);
4485                let start = std::time::Instant::now();
4486                assert!(
4487                    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)
4488                );
4489                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4490                assert_eq!(
4491                    client
4492                        .query("SELECT count(*) AS n FROM tasks", &[])
4493                        .unwrap()[0]["n"],
4494                    rows + 1
4495                );
4496                assert_eq!(
4497                    client
4498                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
4499                        .unwrap()[0]["n"],
4500                    rows + 1
4501                );
4502                assert_eq!(
4503                    client
4504                        .query("SELECT count(*) AS n FROM notes_fts", &[])
4505                        .unwrap()[0]["n"],
4506                    8192
4507                );
4508                assert_eq!(client.pending_commit_ids().len(), 1);
4509                if trial > 0 {
4510                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4511                }
4512            }
4513        }
4514        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});
4515        let output = serde_json::to_string_pretty(&artifact).unwrap();
4516        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4517            use std::io::Write;
4518            let mut file = std::fs::OpenOptions::new()
4519                .write(true)
4520                .create_new(true)
4521                .open(path)
4522                .unwrap();
4523            file.write_all(output.as_bytes()).unwrap();
4524        } else {
4525            println!("{output}");
4526        }
4527    }
4528
4529    #[test]
4530    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4531        let schema = json!({
4532            "version": 1,
4533            "tables": [{
4534                "name": "catalogue_codes",
4535                "primaryKey": "id",
4536                "columns": [
4537                    { "name": "id", "type": "string", "nullable": false },
4538                    { "name": "release_id", "type": "string", "nullable": false },
4539                    { "name": "code", "type": "string", "nullable": false },
4540                    { "name": "title", "type": "string", "nullable": false }
4541                ],
4542                "scopes": [{ "pattern": "release:{release_id}" }],
4543                "ftsIndexes": [{
4544                    "name": "catalogue_codes_fts",
4545                    "columns": ["code", "title"],
4546                    "tokenize": "unicode61 remove_diacritics 2"
4547                }]
4548            }]
4549        });
4550        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4551            .expect("FTS5 client");
4552        let insert_trigger: String = client
4553            .conn
4554            .query_row(
4555                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4556                [],
4557                |row| row.get(0),
4558            )
4559            .expect("insert trigger");
4560        let replace_guard: String = client
4561            .conn
4562            .query_row(
4563                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4564                [],
4565                |row| row.get(0),
4566            )
4567            .expect("replace guard");
4568        assert!(!insert_trigger.contains("DELETE FROM"));
4569        assert!(replace_guard.contains("BEFORE INSERT"));
4570        assert!(replace_guard.contains("WHEN EXISTS"));
4571        let search = |client: &SyncClient, query: &str| {
4572            client
4573                .query(
4574                    "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",
4575                    &[Value::from(query)],
4576                )
4577                .expect("FTS query")
4578        };
4579
4580        client
4581            .mutate(vec![Mutation::Upsert {
4582                table: "catalogue_codes".to_owned(),
4583                values: Map::from_iter([
4584                    ("id".to_owned(), Value::from("c1")),
4585                    ("release_id".to_owned(), Value::from("r1")),
4586                    ("code".to_owned(), Value::from("A01")),
4587                    ("title".to_owned(), Value::from("Cholera")),
4588                ]),
4589                base_version: None,
4590            }])
4591            .expect("insert code");
4592        assert_eq!(search(&client, "cholera").len(), 1);
4593        let prior = client
4594            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4595            .unwrap();
4596        client
4597            .conn
4598            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4599            .unwrap();
4600        client.create_synced_tables().unwrap();
4601        assert_eq!(
4602            client
4603                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4604                .unwrap(),
4605            prior
4606        );
4607        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();
4608        let rendered = serde_json::to_string(&plan).unwrap();
4609        assert!(rendered.contains("COVERING INDEX"));
4610        assert!(rendered.contains("INDEX 0:="));
4611        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4612        assert_eq!(search(&client, "cholera").len(), 1);
4613
4614        client
4615            .mutate(vec![Mutation::Upsert {
4616                table: "catalogue_codes".to_owned(),
4617                values: Map::from_iter([
4618                    ("id".to_owned(), Value::from("c1")),
4619                    ("release_id".to_owned(), Value::from("r1")),
4620                    ("code".to_owned(), Value::from("A01")),
4621                    ("title".to_owned(), Value::from("Enteric infection")),
4622                ]),
4623                base_version: None,
4624            }])
4625            .expect("update code");
4626        assert!(search(&client, "cholera").is_empty());
4627        assert_eq!(search(&client, "enteric").len(), 1);
4628
4629        client
4630            .mutate(vec![Mutation::Delete {
4631                table: "catalogue_codes".to_owned(),
4632                row_id: "c1".to_owned(),
4633                base_version: None,
4634            }])
4635            .expect("delete code");
4636        assert!(search(&client, "enteric").is_empty());
4637    }
4638
4639    #[test]
4640    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4641        let schema = json!({
4642            "version": 1,
4643            "tables": [{
4644                "name": "patient_notes",
4645                "primaryKey": "id",
4646                "columns": [
4647                    { "name": "id", "type": "string", "nullable": false },
4648                    { "name": "practice_id", "type": "string", "nullable": false },
4649                    { "name": "encryption_key_id", "type": "string", "nullable": false },
4650                    { "name": "title", "type": "string", "nullable": false }
4651                ],
4652                "scopes": [{ "pattern": "practice:{practice_id}" }],
4653                "ftsIndexes": [{
4654                    "name": "patient_notes_fts",
4655                    "columns": ["title"],
4656                    "tokenize": "unicode61 remove_diacritics 2"
4657                }]
4658            }]
4659        });
4660        let mut client = SyncClient::new(
4661            "local-purge-test".to_owned(),
4662            &schema,
4663            ClientLimits::default(),
4664        )
4665        .expect("local purge client");
4666        client
4667            .conn
4668            .execute(
4669                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4670                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4671                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4672                [],
4673            )
4674            .expect("seed base rows");
4675        client.overlay_dirty.set(true);
4676        client.rebuild_overlay_if_dirty();
4677
4678        let note = |id: &str, key_id: &str, title: &str| {
4679            Map::from_iter([
4680                ("id".to_owned(), Value::from(id)),
4681                ("practice_id".to_owned(), Value::from("practice-1")),
4682                ("encryption_key_id".to_owned(), Value::from(key_id)),
4683                ("title".to_owned(), Value::from(title)),
4684            ])
4685        };
4686        let doomed = client
4687            .mutate(vec![
4688                Mutation::Upsert {
4689                    table: "patient_notes".to_owned(),
4690                    values: note("target", "key-revoked", "Target changed"),
4691                    base_version: None,
4692                },
4693                Mutation::Upsert {
4694                    table: "patient_notes".to_owned(),
4695                    values: note("unrelated", "key-held", "Unrelated changed"),
4696                    base_version: None,
4697                },
4698            ])
4699            .expect("doomed commit");
4700        let kept = client
4701            .mutate(vec![Mutation::Upsert {
4702                table: "patient_notes".to_owned(),
4703                values: note("kept", "key-held", "Kept optimistic"),
4704                base_version: None,
4705            }])
4706            .expect("kept commit");
4707        client.drain_change_batches();
4708
4709        let input = LocalDataPurgeInput {
4710            purge_id: "purge-001".to_owned(),
4711            targets: vec![LocalDataPurgeTarget {
4712                table: "patient_notes".to_owned(),
4713                selectors: BTreeMap::from([(
4714                    "encryption_key_id".to_owned(),
4715                    vec!["key-revoked".to_owned()],
4716                )]),
4717            }],
4718        };
4719        assert_eq!(
4720            client.purge_local_data(&input).expect("apply purge"),
4721            LocalDataPurgeResult {
4722                already_applied: false,
4723                purged_rows: 1,
4724                dropped_commits: 1,
4725            }
4726        );
4727        let rows = client
4728            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4729            .expect("visible rows");
4730        assert_eq!(rows.len(), 2);
4731        assert_eq!(rows[0]["id"], "kept");
4732        assert_eq!(rows[1]["id"], "unrelated");
4733        assert_eq!(rows[1]["title"], "Unrelated original");
4734        let fts = client
4735            .query(
4736                "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'",
4737                &[],
4738            )
4739            .expect("fts query");
4740        assert!(fts.is_empty());
4741        assert_eq!(client.outbox.len(), 1);
4742        assert_eq!(client.outbox[0].client_commit_id, kept);
4743        let outcome = client
4744            .commit_outcome(&doomed)
4745            .expect("read doomed outcome")
4746            .expect("doomed outcome");
4747        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4748        match &outcome.results[0] {
4749            CommitOperationOutcome::Error { rejection } => {
4750                assert_eq!(rejection.code, "client.local_data_purged");
4751                assert_eq!(rejection.client_commit_id, doomed);
4752            }
4753            other => panic!("expected local purge rejection, got {other:?}"),
4754        }
4755        assert_eq!(client.drain_change_batches().len(), 1);
4756        assert_eq!(
4757            client.purge_local_data(&input).expect("retry purge"),
4758            LocalDataPurgeResult {
4759                already_applied: true,
4760                purged_rows: 0,
4761                dropped_commits: 0,
4762            }
4763        );
4764        let conflicting = LocalDataPurgeInput {
4765            purge_id: input.purge_id.clone(),
4766            targets: vec![LocalDataPurgeTarget {
4767                table: "patient_notes".to_owned(),
4768                selectors: BTreeMap::from([(
4769                    "encryption_key_id".to_owned(),
4770                    vec!["key-held".to_owned()],
4771                )]),
4772            }],
4773        };
4774        assert!(client
4775            .purge_local_data(&conflicting)
4776            .expect_err("id collision must fail")
4777            .contains("already used with a different plan"));
4778    }
4779}
4780
4781impl SubState {
4782    fn name(self) -> &'static str {
4783        match self {
4784            SubState::Active => "active",
4785            SubState::Revoked => "revoked",
4786            SubState::Failed => "failed",
4787        }
4788    }
4789
4790    fn parse(value: &str) -> Self {
4791        match value {
4792            "revoked" => Self::Revoked,
4793            "failed" => Self::Failed,
4794            _ => Self::Active,
4795        }
4796    }
4797}
4798
4799#[derive(Debug, Clone)]
4800struct Subscription {
4801    id: String,
4802    table: String,
4803    requested: Vec<(String, Vec<String>)>,
4804    params: Option<String>,
4805    cursor: i64,
4806    /// §4.7 resume token, round-tripped opaquely.
4807    bootstrap_state: Option<String>,
4808    state: SubState,
4809    reason_code: Option<String>,
4810    /// Last effective scopes echoed while active (§3.3: persisted for the
4811    /// purge contract; each active echo replaces it).
4812    effective: Option<Vec<(String, Vec<String>)>>,
4813    synced_once: bool,
4814}
4815
4816#[derive(Debug, Clone)]
4817struct OutboxOp {
4818    upsert: bool,
4819    table: String,
4820    row_id: String,
4821    base_version: Option<i64>,
4822    /// Schema-agnostic local form (§0): driver JSON values, encoded with
4823    /// the current codec at send time. A key's presence IS the §6.1 sparse
4824    /// presence set — both the overlay and the wire payload derive from it.
4825    values: Option<Map<String, Value>>,
4826}
4827
4828impl From<&OutboxOp> for CommitOperation {
4829    fn from(operation: &OutboxOp) -> Self {
4830        Self {
4831            table: operation.table.clone(),
4832            row_id: operation.row_id.clone(),
4833            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4834            base_version: operation.base_version,
4835            values: operation.values.clone(),
4836        }
4837    }
4838}
4839
4840#[derive(Debug, Clone)]
4841struct OutboxCommit {
4842    client_commit_id: String,
4843    ops: Vec<OutboxOp>,
4844}
4845
4846/// One commit's `(table, value keys)` per upsert operation — the classification
4847/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
4848/// can never disagree about which commits cannot re-encode.
4849fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4850    commit
4851        .ops
4852        .iter()
4853        .filter(|operation| operation.upsert)
4854        .map(|operation| {
4855            (
4856                operation.table.as_str(),
4857                operation
4858                    .values
4859                    .as_ref()
4860                    .map(|values| values.keys().map(String::as_str).collect())
4861                    .unwrap_or_default(),
4862            )
4863        })
4864        .collect()
4865}
4866
4867type FailedCommit = (OutboxCommit, Map<String, Value>);
4868
4869#[derive(Debug, Clone)]
4870struct CompiledLocalDataPurgeTarget {
4871    table: String,
4872    selectors: Vec<(String, Vec<String>)>,
4873}
4874
4875struct StoredCommitOutcomeRow {
4876    sequence: i64,
4877    client_commit_id: String,
4878    status: String,
4879    recorded_at_ms: i64,
4880    results_json: String,
4881    operations_json: Option<String>,
4882    resolution: String,
4883    resolved_at_ms: Option<i64>,
4884    replacement_client_commit_id: Option<String>,
4885}
4886
4887/// Section outcome distinguishing the §5.6 subscription-local fail-closed
4888/// path from a round-aborting failure (§1.4 rule 5).
4889enum SectionError {
4890    FailClosed,
4891    Abort(String, String),
4892}
4893
4894struct RequestMeta {
4895    pushed_ids: Vec<String>,
4896    /// Subscription id → the request carried `cursor < 0` and no resume
4897    /// token (§5.6 first-page detection: a *fresh* bootstrap).
4898    fresh: Vec<(String, bool)>,
4899    accept: u8,
4900    /// §6.1 splitBatch: outbox commits held back from THIS request because
4901    /// the running operation count reached the push cap — the next round
4902    /// pushes them (`sync_needed` stays set while any remain).
4903    deferred_commits: usize,
4904}
4905
4906#[derive(Default)]
4907struct ChangeAccumulator {
4908    /// None means table-wide; Some is the exact scoped domain.
4909    tables: BTreeMap<String, Option<BTreeSet<String>>>,
4910    windows: BTreeMap<(String, String), BTreeSet<String>>,
4911    status: bool,
4912    conflicts: bool,
4913    rejections: bool,
4914    outcomes: bool,
4915}
4916
4917impl ChangeAccumulator {
4918    fn table(&mut self, table: &str) {
4919        self.tables.insert(table.to_owned(), None);
4920    }
4921
4922    fn scope(&mut self, table: &str, key: String) {
4923        match self.tables.get_mut(table) {
4924            Some(None) => {}
4925            Some(Some(keys)) => {
4926                keys.insert(key);
4927            }
4928            None => {
4929                self.tables
4930                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
4931            }
4932        }
4933    }
4934
4935    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4936        self.windows
4937            .entry((base_key.to_owned(), table.to_owned()))
4938            .or_default()
4939            .insert(unit.to_owned());
4940    }
4941
4942    fn touched(&self) -> bool {
4943        !self.tables.is_empty()
4944            || !self.windows.is_empty()
4945            || self.status
4946            || self.conflicts
4947            || self.rejections
4948            || self.outcomes
4949    }
4950}
4951
4952/// §6.1: the server caps total operations per request (reference default
4953/// 500) and rejects the whole batch with `sync.too_many_operations`; the
4954/// client "splits and retries". Splitting happens at build time: commits are
4955/// included IN ORDER until the operation budget is spent, the rest wait for
4956/// the next round.
4957const PUSH_OPS_PER_REQUEST: usize = 500;
4958const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4959const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4960const MAX_LOCAL_PURGE_VALUES: usize = 128;
4961const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4962pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4963
4964pub struct SyncClient {
4965    #[cfg(feature = "bench-internals")]
4966    benchmark_phases: Recorder,
4967    conn: Connection,
4968    schema: ClientSchema,
4969    client_id: String,
4970    limits: ClientLimits,
4971    subs: Vec<Subscription>,
4972    outbox: Vec<OutboxCommit>,
4973    failed_commits: Vec<FailedCommit>,
4974    retain_failed_commits: bool,
4975    conflicts: Vec<ConflictRecord>,
4976    rejections: Vec<RejectionRecord>,
4977    schema_floor: Option<SchemaFloor>,
4978    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
4979    lease_state: Option<LeaseState>,
4980    /// §1.6: the schema-floor response stops syncing until an upgrade.
4981    stopped: bool,
4982    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
4983    upgrading: bool,
4984    /// §8.4 coalesced sync-needed signal.
4985    sync_needed: bool,
4986    /// §8.8: how sync rounds treat the realtime binding, and its exact
4987    /// availability state with the evidence behind a lost or refused one.
4988    realtime_policy: RealtimePolicy,
4989    realtime_state: RealtimeState,
4990    realtime_reason_code: Option<String>,
4991    realtime_retry_delay_ms: Option<u64>,
4992    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
4993    presence: HashMap<String, HashMap<String, PresencePeer>>,
4994    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
4995    /// host may pin it (conformance runs on a virtual clock).
4996    now_ms: Option<i64>,
4997    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
4998    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
4999    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
5000    encryption: crate::values::EncryptionConfig,
5001    /// Fail-closed host bootstrap gate. While set, command hosts permit only
5002    /// status/lifecycle inspection and an exact authorized local purge.
5003    security_preflight: bool,
5004    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
5005    /// means the feature is off; the descriptor write and the container orphan
5006    /// sweep still happen.
5007    previous_version: Option<PreviousVersionContextConfig>,
5008    /// Per-table primary-key upsert SQL, built once per (full table name) —
5009    /// the row write path runs per row during bootstrap (§5.6), so the SQL
5010    /// string (and, via `prepare_cached`, its compiled statement) is reused
5011    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
5012    /// schema reset (the column lists may have changed).
5013    insert_sql: RefCell<HashMap<String, String>>,
5014    /// §7.1 rebuild gate: true whenever the base tables or the outbox have
5015    /// diverged from the visible overlay since the last rebuild. Lets a
5016    /// no-op sync round skip the full base→visible copy.
5017    overlay_dirty: Cell<bool>,
5018    // First SQLite failure of the current operation, before savepoint cleanup.
5019    storage_failure: RefCell<Option<QueryReadFailure>>,
5020    /// Test-only structural performance signal: response processing must not
5021    /// turn a batch of acknowledgements into one full overlay rebuild each.
5022    #[cfg(test)]
5023    overlay_rebuild_count: Cell<usize>,
5024    /// Test-only structural performance signal: outcome retention is enforced
5025    /// once per response rather than once per acknowledgement.
5026    #[cfg(test)]
5027    outcome_prune_count: Cell<usize>,
5028    /// Exact observer-transaction output drained by command/FFI hosts.
5029    progress: ProgressObserver,
5030    change_queue: VecDeque<ClientChangeBatch>,
5031    sync_intent_queue: VecDeque<SyncIntent>,
5032    /// Explicit exponential retry policy for transient transport failures.
5033    retry_delay_ms: u64,
5034    /// §7.6: the background retry the current round scheduled, if any.
5035    round_retry_delay_ms: Option<u64>,
5036    last_round: Option<DiagnosticLastRound>,
5037    last_change: Option<DiagnosticLastChange>,
5038    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
5039    query_failures: Vec<DiagnosticQueryFailure>,
5040}
5041
5042pub(crate) fn quote_ident(name: &str) -> String {
5043    format!("\"{}\"", name.replace('"', "\"\""))
5044}
5045/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
5046/// previous-version module reads and writes the SAME records as the client
5047/// instead of growing a parallel meta layer.
5048pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5049    conn.query_row(
5050        "SELECT value FROM _syncular_meta WHERE key = ?1",
5051        rusqlite::params![key],
5052        |row| row.get::<_, String>(0),
5053    )
5054    .ok()
5055}
5056
5057pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5058    let _ = conn.execute(
5059        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5060        rusqlite::params![key, value],
5061    );
5062}
5063
5064pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5065    let _ = conn.execute(
5066        "DELETE FROM _syncular_meta WHERE key = ?1",
5067        rusqlite::params![key],
5068    );
5069}
5070
5071/// Seek by the existing key while retaining the exact legacy text match.
5072fn row_id_predicate(table: &TableSchema) -> String {
5073    let key = quote_ident(&table.primary_key);
5074    let text_match = format!("CAST({key} AS TEXT) = ?1");
5075    match table.columns[table.pk_index].ty {
5076        ColumnType::String | ColumnType::Json => {
5077            format!("{key} = ?1 AND {text_match}")
5078        }
5079        // Native columns have no declared SQLite affinity. Match the stored
5080        // integer bind type explicitly. Unary + removes expression affinity so
5081        // SQLite can seek the typeless key; the text check preserves exact IDs.
5082        ColumnType::Integer | ColumnType::Boolean => {
5083            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5084        }
5085        // REAL-to-text conversion can round distinct values to the same text.
5086        // Narrowing those matches by numeric equality would change behavior.
5087        _ => text_match,
5088    }
5089}
5090
5091fn is_local_operation_code_like(value: &str) -> bool {
5092    let bytes = value.as_bytes();
5093    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5094        && bytes
5095            .iter()
5096            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5097}
5098
5099fn base_table(name: &str) -> String {
5100    quote_ident(&format!("_syncular_base_{name}"))
5101}
5102
5103/// §4.8: a stable, server-opaque key for a window base — table + variable +
5104/// canonical fixed scopes. Two `set_window` calls with the same base
5105/// address the same registry rows.
5106/// §4.8 deferred eviction record: (sub id, table, effective scope map).
5107type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5108
5109fn window_base_key(base: &WindowBase) -> String {
5110    format!(
5111        "{}\0{}\0{}",
5112        base.table,
5113        base.variable,
5114        canonical_scope_json(&base.fixed_scopes)
5115    )
5116}
5117
5118/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
5119fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5120    let mut scopes = base.fixed_scopes.clone();
5121    scopes.retain(|(k, _)| k != &base.variable);
5122    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5123    scopes
5124}
5125
5126/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
5127/// echoed not interpreted by the server, so the exact hash is client
5128/// convention; SHA-256 matches the SPEC's worked example.
5129fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5130    let canonical = canonical_scope_json(&unit_scopes(base, unit));
5131    let digest = Sha256::digest(canonical.as_bytes());
5132    let hex = bytes_to_hex(&digest);
5133    format!("w:{}:{}", base.table, &hex[..16])
5134}
5135
5136fn visible_table(name: &str) -> String {
5137    quote_ident(name)
5138}
5139
5140const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5141
5142/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
5143/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
5144fn is_synced_table_name(name: &str) -> bool {
5145    if name.starts_with("sqlite_") {
5146        return false;
5147    }
5148    if name.starts_with("_syncular_base_") {
5149        return true; // the base half of a synced table pair
5150    }
5151    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
5152    !name.starts_with("_syncular_")
5153}
5154
5155/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
5156fn blob_id_for(bytes: &[u8]) -> String {
5157    let digest = Sha256::digest(bytes);
5158    format!("sha256:{}", bytes_to_hex(&digest))
5159}
5160
5161/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
5162/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
5163/// per row on the §5.6 bootstrap path), scalars bind owned.
5164enum RowParam<'a> {
5165    Cell(&'a Option<ColumnValue>),
5166    Version(i64),
5167}
5168
5169impl rusqlite::ToSql for RowParam<'_> {
5170    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5171        Ok(match self {
5172            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5173            RowParam::Cell(cell) => match cell {
5174                None => ToSqlOutput::Owned(SqlValue::Null),
5175                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5176                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5177                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5178                Some(ColumnValue::Boolean(b)) => {
5179                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5180                }
5181                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5182                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5183                }
5184                // §5.10: crdt bytes store as BLOB, like bytes.
5185                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5186                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
5187                }
5188            },
5189        })
5190    }
5191}
5192
5193// Retain a typed primary key across the current import chunk without copying row payloads.
5194fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5195    match param.to_sql().map_err(|error| error.to_string())? {
5196        ToSqlOutput::Owned(value) => Ok(value),
5197        ToSqlOutput::Borrowed(value) => {
5198            SqlValue::try_from(value).map_err(|error| error.to_string())
5199        }
5200        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5201    }
5202}
5203
5204/// §5.3 image cell → bind parameter, strict per the declared column type
5205/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
5206/// nullable). Mismatches are image-producer violations. Returns the cell
5207/// borrowed when the stored representation already matches what the row
5208/// codec would write, or the normalized scalar (boolean → 0/1, float from
5209/// INTEGER → REAL) otherwise — the local write is byte-identical to the
5210/// old convert-then-insert path without allocating per cell.
5211fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5212    use ssp2::segment::ColumnType;
5213    let mismatch = || {
5214        Err(format!(
5215            "image column {:?} holds a value of the wrong type",
5216            column.name
5217        ))
5218    };
5219    match value {
5220        ValueRef::Null => {
5221            if !column.nullable {
5222                return Err(format!(
5223                    "image column {:?} is NULL but not nullable",
5224                    column.name
5225                ));
5226            }
5227            Ok(ToSqlOutput::Owned(SqlValue::Null))
5228        }
5229        ValueRef::Integer(i) => match column.ty {
5230            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5231            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5232            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5233            _ => mismatch(),
5234        },
5235        ValueRef::Real(_) => match column.ty {
5236            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5237            _ => mismatch(),
5238        },
5239        ValueRef::Text(t) => {
5240            std::str::from_utf8(t)
5241                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5242            match column.ty {
5243                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5244                    Ok(ToSqlOutput::Borrowed(value))
5245                }
5246                _ => mismatch(),
5247            }
5248        }
5249        ValueRef::Blob(_) => match column.ty {
5250            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
5251            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5252            _ => mismatch(),
5253        },
5254    }
5255}
5256
5257pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5258    use rusqlite::types::ValueRef;
5259    match value {
5260        ValueRef::Null => Value::Null,
5261        ValueRef::Integer(i) => match column.ty {
5262            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5263            ssp2::segment::ColumnType::Float => {
5264                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5265            }
5266            _ => Value::from(i),
5267        },
5268        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5269        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5270        ValueRef::Blob(b) => {
5271            let mut map = Map::new();
5272            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5273            Value::Object(map)
5274        }
5275    }
5276}
5277
5278/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
5279/// Objects are accepted in lossless `{"$bytes": hex}` and
5280/// `{"$bigint": decimal}` envelope forms.
5281fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5282    Ok(match value {
5283        Value::Null => SqlValue::Null,
5284        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5285        Value::Number(n) => {
5286            if let Some(i) = n.as_i64() {
5287                SqlValue::Integer(i)
5288            } else if let Some(f) = n.as_f64() {
5289                SqlValue::Real(f)
5290            } else {
5291                return Err(format!("query param number {n} is out of range"));
5292            }
5293        }
5294        Value::String(s) => SqlValue::Text(s.clone()),
5295        Value::Object(_) => {
5296            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5297                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5298            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5299                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5300                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5301                })?)
5302            } else {
5303                return Err(
5304                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5305                        .to_owned(),
5306                );
5307            }
5308        }
5309        Value::Array(_) => return Err("query array params are not supported".to_owned()),
5310    })
5311}
5312
5313/// Map a rusqlite value with no schema column to consult (arbitrary query
5314/// output): integers/reals/text pass through by stored affinity, blobs ride
5315/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
5316/// schema column type to recover booleans/floats/json.
5317fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5318    use rusqlite::types::ValueRef;
5319    match value {
5320        ValueRef::Null => Value::Null,
5321        ValueRef::Integer(i) => {
5322            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
5323            // ordinary UI-sized integers ergonomic and envelope only values
5324            // beyond JavaScript's safe range.
5325            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5326            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5327                Value::from(i)
5328            } else {
5329                let mut map = Map::new();
5330                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5331                Value::Object(map)
5332            }
5333        }
5334        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5335        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5336        ValueRef::Blob(b) => {
5337            let mut map = Map::new();
5338            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5339            Value::Object(map)
5340        }
5341    }
5342}
5343
5344/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
5345fn query_connection(
5346    conn: &Connection,
5347    sql: &str,
5348    params: &[QueryValue],
5349) -> Result<Vec<QueryRow>, QueryReadFailure> {
5350    crate::query_guard::assert_read_only_query(sql)?;
5351    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5352    let bound: Vec<SqlValue> = params
5353        .iter()
5354        .map(json_param_to_sql)
5355        .collect::<Result<_, _>>()?;
5356    let mut stmt = conn.prepare(&lowered_sql)?;
5357    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5358    let bound_refs: Vec<&dyn rusqlite::ToSql> =
5359        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5360    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5361    let mut out = Vec::new();
5362    while let Some(row) = sql_rows.next()? {
5363        let mut record = Map::new();
5364        for (i, name) in column_names.iter().enumerate() {
5365            let value = row.get_ref(i)?;
5366            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5367        }
5368        out.push(record);
5369    }
5370    Ok(out)
5371}
5372
5373fn persisted_window_state(
5374    conn: &Connection,
5375    base: &WindowBase,
5376) -> Result<WindowState, QueryReadFailure> {
5377    let mut stmt = conn.prepare(
5378        "SELECT windows.unit, subscriptions.state_json
5379           FROM _syncular_windows AS windows
5380           JOIN _syncular_subscriptions AS subscriptions
5381             ON subscriptions.id = windows.sub_id
5382          WHERE windows.base = ?1
5383          ORDER BY windows.unit ASC",
5384    )?;
5385    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5386        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5387    })?;
5388    let mut units = Vec::new();
5389    let mut pending = Vec::new();
5390    for row in rows {
5391        let (unit, raw) = row?;
5392        let state: Value = serde_json::from_str(&raw)
5393            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5394        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5395            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5396            || state
5397                .get("bootstrapState")
5398                .is_some_and(|value| !value.is_null());
5399        if is_pending {
5400            pending.push(unit.clone());
5401        }
5402        units.push(unit);
5403    }
5404    Ok(WindowState { units, pending })
5405}
5406
5407fn snapshot_connection(
5408    conn: &Connection,
5409    sql: &str,
5410    params: &[Value],
5411    coverage: &[WindowCoverage],
5412) -> Result<QuerySnapshot, QueryReadFailure> {
5413    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5414    let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
5415        let revision = conn
5416            .query_row(
5417                "SELECT value FROM _syncular_meta WHERE key = ?1",
5418                rusqlite::params![LOCAL_REVISION_KEY],
5419                |row| row.get::<_, String>(0),
5420            )
5421            .ok()
5422            .and_then(|value| value.parse::<u64>().ok())
5423            .unwrap_or(0);
5424        let rows = query_connection(conn, sql, params)?;
5425        let mut pending = Vec::new();
5426        let mut missing = Vec::new();
5427        for requested in coverage {
5428            let base_key = window_base_key(&requested.base);
5429            let state = persisted_window_state(conn, &requested.base)?;
5430            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5431                let reference = WindowUnitRef {
5432                    base_key: base_key.clone(),
5433                    unit: unit.clone(),
5434                };
5435                if !state.units.iter().any(|held| held == &unit) {
5436                    missing.push(reference);
5437                } else if state.pending.iter().any(|held| held == &unit) {
5438                    pending.push(reference);
5439                }
5440            }
5441        }
5442        Ok(QuerySnapshot {
5443            revision: revision.to_string(),
5444            rows,
5445            coverage: CoverageSnapshot {
5446                complete: pending.is_empty() && missing.is_empty(),
5447                pending,
5448                missing,
5449            },
5450        })
5451    })();
5452    match result {
5453        Ok(snapshot) => {
5454            conn.execute_batch("RELEASE syncular_snapshot_read")?;
5455            Ok(snapshot)
5456        }
5457        Err(mut error) => {
5458            if let Err(rollback) = conn
5459                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
5460            {
5461                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
5462            }
5463            Err(error)
5464        }
5465    }
5466}
5467
5468/// A long-lived read-only SQLite sidecar for latency-critical native views.
5469/// Network rounds stay serialized on the mutable core owner, while atomic
5470/// query snapshots use this independent connection and therefore never queue
5471/// behind HTTP/WebSocket latency.
5472pub struct FileQuerySnapshotReader {
5473    path: String,
5474    conn: Option<Connection>,
5475}
5476
5477impl FileQuerySnapshotReader {
5478    #[must_use]
5479    pub fn new(path: impl Into<String>) -> Self {
5480        Self {
5481            path: path.into(),
5482            conn: None,
5483        }
5484    }
5485
5486    fn connection(&mut self) -> Result<&Connection, String> {
5487        if self.conn.is_none() {
5488            let conn = Connection::open_with_flags(
5489                &self.path,
5490                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5491            )
5492            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5493            conn.busy_timeout(std::time::Duration::from_millis(250))
5494                .map_err(|error| error.to_string())?;
5495            self.conn = Some(conn);
5496        }
5497        self.conn
5498            .as_ref()
5499            .ok_or_else(|| "read sidecar connection missing".to_owned())
5500    }
5501
5502    /// Hosts report an owned failure, and the first success after it, to the
5503    /// owning core through [`SyncClient::record_query_read`] (§7.6).
5504    pub fn query_snapshot(
5505        &mut self,
5506        sql: &str,
5507        params: &[Value],
5508        coverage: &[WindowCoverage],
5509    ) -> Result<QuerySnapshot, QueryReadFailure> {
5510        snapshot_connection(self.connection()?, sql, params, coverage)
5511    }
5512}
5513
5514impl SyncClient {
5515    pub fn new_with_identity(
5516        client_id: Option<String>,
5517        schema_json: &Value,
5518        limits: ClientLimits,
5519    ) -> Result<Self, String> {
5520        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5521        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5522        Self::with_connection(resolved, schema_json, limits, conn)
5523    }
5524
5525    pub fn new(
5526        client_id: String,
5527        schema_json: &Value,
5528        limits: ClientLimits,
5529    ) -> Result<Self, String> {
5530        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5531        Self::with_connection(client_id, schema_json, limits, conn)
5532    }
5533
5534    /// Build a client backed by an on-disk SQLite database at `path` — the
5535    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
5536    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
5537    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
5538    /// of the command router's dependency set (the router only holds a path).
5539    pub fn open_path(
5540        client_id: String,
5541        schema_json: &Value,
5542        limits: ClientLimits,
5543        path: &str,
5544    ) -> Result<Self, String> {
5545        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5546        Self::with_connection(client_id, schema_json, limits, conn)
5547    }
5548
5549    pub fn open_path_with_identity(
5550        client_id: Option<String>,
5551        schema_json: &Value,
5552        limits: ClientLimits,
5553        path: &str,
5554    ) -> Result<Self, String> {
5555        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5556        // File-backed native clients use an independent read connection for
5557        // latency-critical snapshots. WAL is SQLite's intended reader/writer
5558        // concurrency mode: a view read never holds a rollback-journal lock
5559        // that delays the mutable client's next commit, and a short busy
5560        // timeout absorbs the tiny checkpoint/schema-lock windows.
5561        conn.busy_timeout(std::time::Duration::from_millis(250))
5562            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5563        conn.pragma_update(None, "journal_mode", "WAL")
5564            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5565        let persisted = conn
5566            .query_row(
5567                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5568                [],
5569                |row| row.get::<_, String>(0),
5570            )
5571            .ok();
5572        let resolved = persisted
5573            .clone()
5574            .or(client_id.clone())
5575            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5576        if let (Some(existing), Some(requested)) = (persisted, client_id) {
5577            if existing != requested {
5578                return Err(format!(
5579                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5580                ));
5581            }
5582        }
5583        Self::with_connection(resolved, schema_json, limits, conn)
5584    }
5585
5586    #[must_use]
5587    pub fn client_id(&self) -> &str {
5588        &self.client_id
5589    }
5590
5591    /// Build a client over a caller-supplied rusqlite connection — the seam a
5592    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
5593    /// with an on-disk database (`Connection::open(path)`) rather than the
5594    /// default `:memory:`. The connection MUST be fresh (no pre-existing
5595    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
5596    /// the same file across process restarts reuses the persisted rows.
5597    pub fn with_connection(
5598        client_id: String,
5599        schema_json: &Value,
5600        limits: ClientLimits,
5601        conn: Connection,
5602    ) -> Result<Self, String> {
5603        if limits.outcome_retention_max_entries == Some(0) {
5604            return Err(
5605                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5606            );
5607        }
5608        let schema = parse_schema_json(schema_json)?;
5609        // RFC 0005 D8: resolve the previous-version config before the opening
5610        // reset runs — the capture happens inside it. Bad bounds fail loud.
5611        let previous_version = match limits.previous_version_context {
5612            Some(config) => {
5613                config.validate()?;
5614                Some(config)
5615            }
5616            None => None,
5617        };
5618        let mut client = SyncClient {
5619            #[cfg(feature = "bench-internals")]
5620            benchmark_phases: Recorder::default(),
5621            conn,
5622            schema,
5623            client_id,
5624            limits,
5625            subs: Vec::new(),
5626            outbox: Vec::new(),
5627            failed_commits: Vec::new(),
5628            retain_failed_commits: false,
5629            conflicts: Vec::new(),
5630            rejections: Vec::new(),
5631            schema_floor: None,
5632            lease_state: None,
5633            stopped: false,
5634            upgrading: false,
5635            sync_needed: false,
5636            realtime_policy: RealtimePolicy::Optional,
5637            realtime_state: RealtimeState::Disconnected,
5638            realtime_reason_code: None,
5639            realtime_retry_delay_ms: None,
5640            presence: HashMap::new(),
5641            now_ms: None,
5642            encryption: crate::values::EncryptionConfig::default(),
5643            security_preflight: false,
5644            previous_version,
5645            insert_sql: RefCell::new(HashMap::new()),
5646            overlay_dirty: Cell::new(false),
5647            storage_failure: RefCell::new(None),
5648            #[cfg(test)]
5649            overlay_rebuild_count: Cell::new(0),
5650            #[cfg(test)]
5651            outcome_prune_count: Cell::new(0),
5652            progress: ProgressObserver::default(),
5653            change_queue: VecDeque::new(),
5654            sync_intent_queue: VecDeque::new(),
5655            retry_delay_ms: 250,
5656            round_retry_delay_ms: None,
5657            last_round: None,
5658            last_change: None,
5659            query_failures: Vec::new(),
5660        };
5661        // The row write path leans on the prepared-statement cache (two
5662        // insert statements per synced table, plus the bookkeeping
5663        // statements); size it so a multi-table schema never thrashes.
5664        client
5665            .conn
5666            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5667        // Protected bookkeeping must exist before inspecting the persisted
5668        // schema marker. New app indexes may reference columns that only
5669        // exist after a version-bump reset.
5670        client.create_bookkeeping_tables()?;
5671        match client.get_meta(CLIENT_ID_KEY) {
5672            Some(existing) if existing != client.client_id => {
5673                return Err(format!(
5674                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5675                    client.client_id
5676                ));
5677            }
5678            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5679            _ => {}
5680        }
5681        client.restore_persisted_state()?;
5682        client.failed_commits = client.load_failed_commits()?;
5683        let marker = client
5684            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5685            .and_then(|value| value.parse::<i32>().ok());
5686        match marker {
5687            None => {
5688                client.create_synced_tables()?;
5689                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5690                // D1: the descriptor is written beside every marker write.
5691                set_local_schema_descriptor(&client.conn, &client.schema);
5692            }
5693            Some(version) if version == client.schema.version => {
5694                client.create_synced_tables()?;
5695                // RFC 0005 D1: the same-version open backfills the descriptor
5696                // for a database first opened by an unaware binary, so the
5697                // NEXT bump can capture without needing a schema bump first.
5698                set_local_schema_descriptor(&client.conn, &client.schema);
5699            }
5700            Some(_) => client.run_schema_reset()?,
5701        }
5702        // RFC 0005 D9: the reset sweep cannot see a container whose capture
5703        // committed in its OWN file while the replica's savepoint never
5704        // released — a crash at that seam leaves a container beside a matching
5705        // OLD marker, and the next open at that version runs no reset. Discard
5706        // it before anything can read it. No-op when no container exists.
5707        client.reconcile_previous_version_at_boot()?;
5708        client.clear_satisfied_persisted_schema_floor();
5709        client.prune_unknown_subscriptions()?;
5710        if marker == Some(client.schema.version)
5711            && (!client.outbox.is_empty() || !client.failed_commits.is_empty())
5712        {
5713            // Reconstruct the visible optimistic overlay from the durable base
5714            // plus outbox instead of trusting a process-interrupted mirror.
5715            client.overlay_dirty.set(true);
5716            client.rebuild_overlay();
5717        }
5718        // Every persisted active subscription needs one catch-up pull on open:
5719        // realtime only covers changes after connection, while an idempotent
5720        // setWindow correctly creates no fresh command effect. Pending outbox
5721        // work has the same restart requirement. The core owns this intent so
5722        // native hosts never poll or require an application-issued sync().
5723        client.enqueue_startup_sync_if_needed()?;
5724        Ok(client)
5725    }
5726
5727    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
5728    /// against this instead of system time (conformance virtual clock).
5729    pub fn set_now_ms(&mut self, now_ms: i64) {
5730        self.now_ms = Some(now_ms);
5731    }
5732
5733    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
5734    /// The command router parses these from the `create` command's
5735    /// `encryption` config (keys as `{$bytes: hex}`).
5736    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5737        self.encryption = encryption;
5738    }
5739
5740    #[must_use]
5741    pub fn security_lifecycle(&self) -> &'static str {
5742        if self.security_preflight {
5743            "preflight"
5744        } else {
5745            "active"
5746        }
5747    }
5748
5749    #[must_use]
5750    pub fn security_preflight(&self) -> bool {
5751        self.security_preflight
5752    }
5753
5754    /// Quarantine this replica: enter the fail-closed gate, release all
5755    /// core-owned key material, and record the gate in the database so
5756    /// reopening re-enters preflight until `activate_security` clears it.
5757    ///
5758    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
5759    /// which must NOT leave that durable mark.
5760    pub fn begin_security_preflight(&mut self) {
5761        self.seal_security_on_teardown();
5762        // Persist the quarantine so it survives handle teardown and restart:
5763        // reopening this replica re-enters preflight until activation clears it.
5764        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5765    }
5766
5767    /// Release core-owned key material as a host tears the client down,
5768    /// WITHOUT recording a quarantine.
5769    ///
5770    /// Shutting an activated client down is not a quarantine event. Persisting
5771    /// the gate here marks every cleanly closed replica as pending, and the
5772    /// next plain `create` is then refused permanently: the reopen path
5773    /// restores the flag from the marker and the create guard rejects it. The
5774    /// in-memory flag still closes the gate for anything still holding this
5775    /// instance, which is all a teardown barrier needs.
5776    pub fn seal_security_on_teardown(&mut self) {
5777        self.security_preflight = true;
5778        self.encryption = crate::values::EncryptionConfig::default();
5779        self.sync_intent_queue.clear();
5780    }
5781
5782    /// Install the post-authentication keyring and release the host loop.
5783    pub fn activate_security(
5784        &mut self,
5785        encryption: crate::values::EncryptionConfig,
5786    ) -> Result<(), String> {
5787        if !self.security_preflight {
5788            return Err(
5789                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5790            );
5791        }
5792        self.enqueue_startup_sync_if_needed()?;
5793        self.encryption = encryption;
5794        self.security_preflight = false;
5795        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5796        Ok(())
5797    }
5798
5799    fn clock_now_ms(&self) -> i64 {
5800        self.now_ms.unwrap_or_else(|| {
5801            std::time::SystemTime::now()
5802                .duration_since(std::time::UNIX_EPOCH)
5803                .map(|d| d.as_millis() as i64)
5804                .unwrap_or(0)
5805        })
5806    }
5807
5808    fn create_bookkeeping_tables(&self) -> Result<(), String> {
5809        // Durable client bookkeeping (outbox + subscription + meta).
5810        self.conn
5811            .execute_batch(
5812                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5813                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5814                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5815                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
5816                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
5817                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
5818                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5819                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5820                   client_commit_id TEXT NOT NULL UNIQUE,
5821                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5822                   recorded_at_ms INTEGER NOT NULL,
5823                   results_json TEXT NOT NULL,
5824                   operations_json TEXT,
5825                   resolution TEXT NOT NULL DEFAULT 'active'
5826                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5827                   resolved_at_ms INTEGER,
5828                   replacement_client_commit_id TEXT);
5829                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5830                   ON _syncular_commit_outcomes(resolution, seq);
5831                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5832                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5833                 CREATE TABLE IF NOT EXISTS _syncular_meta (
5834                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
5835                 CREATE TABLE IF NOT EXISTS _syncular_windows (
5836                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5837                   PRIMARY KEY (base, unit));
5838                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5839                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5840                   effective_scopes TEXT NOT NULL);
5841                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5842                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5843                   PRIMARY KEY(commit_id, blob_id));
5844                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5845                   ON _syncular_blob_commit_refs(blob_id);",
5846            )
5847            .map_err(|e| e.to_string())?;
5848        // Migrate an outcome journal created before failed aggregate
5849        // envelopes were retained. Historical rows intentionally stay NULL.
5850        let _ = self
5851            .conn
5852            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5853        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5854            self.set_meta(LOCAL_REVISION_KEY, "0");
5855        }
5856        // §5.9.7 blob cache. Mutable upload state stays off the body row so
5857        // completing a large upload does not rewrite its BLOB record.
5858        if self.schema_has_blobs() {
5859            let exists = self
5860                .conn
5861                .query_row(
5862                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5863                    [],
5864                    |row| row.get::<_, bool>(0),
5865                )
5866                .map_err(|e| e.to_string())?;
5867            if exists {
5868                let mut statement = self
5869                    .conn
5870                    .prepare("PRAGMA table_info(_syncular_blobs)")
5871                    .map_err(|e| e.to_string())?;
5872                let mut columns = statement
5873                    .query_map([], |row| row.get::<_, String>(1))
5874                    .map_err(|e| e.to_string())?
5875                    .collect::<Result<Vec<_>, _>>()
5876                    .map_err(|e| e.to_string())?;
5877                columns.sort();
5878                let mut expected = vec![
5879                    "blob_id",
5880                    "byte_length",
5881                    "bytes",
5882                    "created_at_ms",
5883                    "media_type",
5884                ];
5885                expected.sort_unstable();
5886                if columns != expected {
5887                    return Err(
5888                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5889                    );
5890                }
5891            } else {
5892                self.conn
5893                    .execute_batch(
5894                        "CREATE TABLE _syncular_blobs(
5895                           blob_id TEXT PRIMARY KEY,
5896                           bytes BLOB NOT NULL,
5897                           byte_length INTEGER NOT NULL,
5898                           media_type TEXT,
5899                           created_at_ms INTEGER NOT NULL);",
5900                    )
5901                    .map_err(|e| e.to_string())?;
5902            }
5903            self.conn
5904                .execute_batch(
5905                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5906                       blob_id TEXT PRIMARY KEY,
5907                       media_type TEXT,
5908                       created_at_ms INTEGER NOT NULL);",
5909                )
5910                .map_err(|e| e.to_string())?;
5911        }
5912        Ok(())
5913    }
5914
5915    /// True iff any synced table declares a `blob_ref` column (§5.9).
5916    fn schema_has_blobs(&self) -> bool {
5917        self.schema
5918            .tables
5919            .iter()
5920            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5921    }
5922
5923    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
5924
5925    fn get_meta(&self, key: &str) -> Option<String> {
5926        meta_get(&self.conn, key)
5927    }
5928
5929    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5930        self.conn
5931            .query_row(
5932                "SELECT value FROM _syncular_meta WHERE key = ?1",
5933                rusqlite::params![key],
5934                |row| row.get::<_, String>(0),
5935            )
5936            .optional()
5937            .map_err(|_| {
5938                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5939            })
5940    }
5941
5942    fn set_meta(&self, key: &str, value: &str) {
5943        meta_set(&self.conn, key, value);
5944    }
5945
5946    fn delete_meta(&self, key: &str) {
5947        meta_delete(&self.conn, key);
5948    }
5949
5950    fn restore_persisted_state(&mut self) -> Result<(), String> {
5951        self.subs = {
5952            let mut stmt = self
5953                .conn
5954                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5955                .map_err(|error| error.to_string())?;
5956            let rows = stmt
5957                .query_map([], |row| {
5958                    Ok((
5959                        row.get::<_, String>(0)?,
5960                        row.get::<_, String>(1)?,
5961                        row.get::<_, String>(2)?,
5962                    ))
5963                })
5964                .map_err(|error| error.to_string())?;
5965            let mut subscriptions = Vec::new();
5966            for row in rows {
5967                let (id, table, raw) = row.map_err(|error| error.to_string())?;
5968                let state: Value = serde_json::from_str(&raw)
5969                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5970                let requested = json_to_scope_map(
5971                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
5972                )?;
5973                let effective = state
5974                    .get("effectiveScopes")
5975                    .filter(|value| !value.is_null())
5976                    .map(json_to_scope_map)
5977                    .transpose()?;
5978                subscriptions.push(Subscription {
5979                    id,
5980                    table,
5981                    requested,
5982                    params: state
5983                        .get("params")
5984                        .and_then(Value::as_str)
5985                        .map(str::to_owned),
5986                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5987                    bootstrap_state: state
5988                        .get("bootstrapState")
5989                        .and_then(Value::as_str)
5990                        .map(str::to_owned),
5991                    state: SubState::parse(
5992                        state
5993                            .get("status")
5994                            .and_then(Value::as_str)
5995                            .unwrap_or("active"),
5996                    ),
5997                    reason_code: state
5998                        .get("reasonCode")
5999                        .and_then(Value::as_str)
6000                        .map(str::to_owned),
6001                    effective,
6002                    synced_once: state
6003                        .get("syncedOnce")
6004                        .and_then(Value::as_bool)
6005                        .unwrap_or(false),
6006                });
6007            }
6008            subscriptions
6009        };
6010
6011        self.outbox = {
6012            let mut stmt = self
6013                .conn
6014                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6015                .map_err(|error| error.to_string())?;
6016            let rows = stmt
6017                .query_map([], |row| {
6018                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6019                })
6020                .map_err(|error| error.to_string())?;
6021            let mut commits = Vec::new();
6022            for row in rows {
6023                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6024                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6025                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
6026                })?;
6027                let mut ops = Vec::with_capacity(entries.len());
6028                for entry in entries {
6029                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6030                    ops.push(OutboxOp {
6031                        upsert: op == "upsert",
6032                        table: entry
6033                            .get("table")
6034                            .and_then(Value::as_str)
6035                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6036                            .to_owned(),
6037                        row_id: entry
6038                            .get("rowId")
6039                            .and_then(Value::as_str)
6040                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6041                            .to_owned(),
6042                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
6043                        // A legacy entry's `changedFields` key is ignored:
6044                        // the values map is the presence set (§6.1).
6045                        values: entry.get("values").and_then(Value::as_object).cloned(),
6046                    });
6047                }
6048                commits.push(OutboxCommit {
6049                    client_commit_id,
6050                    ops,
6051                });
6052            }
6053            commits
6054        };
6055
6056        self.prune_commit_outcomes()?;
6057        let active = self.commit_outcomes(CommitOutcomeQuery {
6058            active_only: true,
6059            ..CommitOutcomeQuery::default()
6060        })?;
6061        self.conflicts = active
6062            .iter()
6063            .flat_map(|outcome| outcome.results.iter())
6064            .filter_map(|result| match result {
6065                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6066                _ => None,
6067            })
6068            .collect();
6069        self.rejections = active
6070            .iter()
6071            .flat_map(|outcome| outcome.results.iter())
6072            .filter_map(|result| match result {
6073                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
6074                _ => None,
6075            })
6076            .collect();
6077
6078        self.lease_state = self
6079            .get_meta(LEASE_STATE_KEY)
6080            .map(|raw| serde_json::from_str(&raw))
6081            .transpose()
6082            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
6083        self.schema_floor = self
6084            .get_meta(SCHEMA_FLOOR_KEY)
6085            .map(|raw| serde_json::from_str(&raw))
6086            .transpose()
6087            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
6088        self.stopped = self.schema_floor.is_some();
6089        // A replica left in unactivated preflight reopens gated: the quarantine
6090        // decision persists with the data across handle teardown and restart.
6091        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
6092            self.security_preflight = true;
6093        }
6094        Ok(())
6095    }
6096
6097    /// Remove registrations for tables the running schema no longer knows.
6098    /// Otherwise the server rejects every pull with `sync.unknown_table`.
6099    fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
6100        let valid_tables: BTreeSet<String> = self
6101            .schema
6102            .tables
6103            .iter()
6104            .map(|table| table.name.clone())
6105            .collect();
6106        let stale_ids: Vec<String> = self
6107            .subs
6108            .iter()
6109            .filter(|sub| !valid_tables.contains(&sub.table))
6110            .map(|sub| sub.id.clone())
6111            .collect();
6112        for id in &stale_ids {
6113            self.conn
6114                .execute(
6115                    "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6116                    rusqlite::params![id],
6117                )
6118                .map_err(|error| error.to_string())?;
6119            self.conn
6120                .execute(
6121                    "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6122                    rusqlite::params![id],
6123                )
6124                .map_err(|error| error.to_string())?;
6125            self.conn
6126                .execute(
6127                    "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6128                    rusqlite::params![id],
6129                )
6130                .map_err(|error| error.to_string())?;
6131        }
6132        self.subs.retain(|sub| valid_tables.contains(&sub.table));
6133        Ok(())
6134    }
6135
6136    #[must_use]
6137    pub fn local_revision(&self) -> u64 {
6138        self.get_meta(LOCAL_REVISION_KEY)
6139            .and_then(|value| value.parse().ok())
6140            .unwrap_or(0)
6141    }
6142
6143    #[must_use]
6144    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
6145        SyncStatusSnapshot {
6146            current_schema_version: self.schema.version,
6147            outbox: self.outbox.len(),
6148            upgrading: self.upgrading,
6149            lease_state: self.lease_state.clone(),
6150            schema_floor: self.schema_floor.clone(),
6151            sync_needed: self.sync_needed,
6152            previous_version_context: self.previous_version_status(),
6153        }
6154    }
6155
6156    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
6157    /// rollback precondition), read without creating the file.
6158    fn previous_version_status(&self) -> PreviousVersionStatus {
6159        let Some(replica_path) = self.previous_version_replica_path() else {
6160            return PreviousVersionStatus {
6161                present: false,
6162                created_at_ms: None,
6163            };
6164        };
6165        match crate::previous_version::read_previous_version_record(&replica_path) {
6166            Ok(Some(record)) => PreviousVersionStatus {
6167                present: true,
6168                created_at_ms: Some(record.created_at_ms),
6169            },
6170            _ => PreviousVersionStatus {
6171                present: std::path::Path::new(
6172                    &crate::previous_version::previous_version_container_path(&replica_path),
6173                )
6174                .exists(),
6175                created_at_ms: None,
6176            },
6177        }
6178    }
6179
6180    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
6181    /// is always `previousVersion`; the container never makes
6182    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
6183    /// revocation, coverage completion and the TTL DISCARD the container and
6184    /// close the read with their own reason.
6185    pub fn previous_version_snapshot(
6186        &self,
6187        spec: &PreviousVersionReadSpec,
6188    ) -> Result<PreviousVersionSnapshot, String> {
6189        let now_ms = self.clock_now_ms();
6190        crate::previous_version::previous_version_snapshot(
6191            &self.conn,
6192            self.previous_version_replica_path().as_deref(),
6193            self.previous_version.as_ref(),
6194            PreviousVersionLifecycle {
6195                lease_inactive: self.previous_version_lease_inactive(now_ms),
6196                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6197                coverage_complete: self.previous_version_coverage_complete(),
6198            },
6199            now_ms,
6200            self.schema.version,
6201            spec,
6202        )
6203    }
6204
6205    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
6206    #[must_use]
6207    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6208        crate::previous_version::stored_previous_version_audit(&self.conn)
6209    }
6210
6211    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
6212    /// and both metadata records, and report whether anything was present.
6213    /// Idempotent: absence is a successful no-op.
6214    pub fn previous_version_discard(
6215        &self,
6216    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6217        self.drop_previous_version()
6218    }
6219
6220    /// The module's discard has no failure path (it removes the file even when
6221    /// the container cannot be opened), so the only error a caller could ever
6222    /// see is a bug; report absence rather than claiming a discard happened.
6223    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6224        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6225            present: false,
6226            discarded: false,
6227        };
6228        let Some(replica_path) = self.previous_version_replica_path() else {
6229            return absent;
6230        };
6231        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6232            .unwrap_or(absent)
6233    }
6234
6235    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
6236    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6237        let Some(lease) = &self.lease_state else {
6238            return false;
6239        };
6240        if lease.error_code.is_some() {
6241            return true;
6242        }
6243        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6244    }
6245
6246    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
6247    /// has `cursor >= 0` and no resume token. An empty active set is not
6248    /// completion (mirrors the TS predicate exactly).
6249    fn previous_version_coverage_complete(&self) -> bool {
6250        let active = self
6251            .subs
6252            .iter()
6253            .filter(|sub| sub.state == SubState::Active)
6254            .collect::<Vec<_>>();
6255        if active.is_empty() {
6256            return false;
6257        }
6258        active
6259            .iter()
6260            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6261    }
6262
6263    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
6264    /// apply. A container that no longer has a reason to exist is discarded so
6265    /// the rows do not outlive the window they were kept for.
6266    fn previous_version_lifetime_check(&self) {
6267        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6268            return;
6269        };
6270        let now_ms = self.clock_now_ms();
6271        if self.previous_version_lease_inactive(now_ms) {
6272            let _ = self.drop_previous_version();
6273            return;
6274        }
6275        let Some(replica_path) = self.previous_version_replica_path() else {
6276            return;
6277        };
6278        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6279        else {
6280            return;
6281        };
6282        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6283            let _ = self.drop_previous_version();
6284            return;
6285        }
6286        if now_ms - record.created_at_ms > config.max_age_ms {
6287            let _ = self.drop_previous_version();
6288            return;
6289        }
6290        if self.previous_version_coverage_complete() {
6291            let _ = self.drop_previous_version();
6292        }
6293    }
6294
6295    pub fn diagnostics_snapshot(
6296        &self,
6297        request: &ClientDiagnosticsRequest,
6298    ) -> Result<ClientDiagnosticsSnapshot, String> {
6299        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6300            return Err(format!(
6301                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6302            ));
6303        }
6304        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6305        for sub in &self.subs {
6306            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6307            let complete =
6308                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6309            let state = match sub.state {
6310                SubState::Revoked => "revoked",
6311                SubState::Failed => "failed",
6312                SubState::Active if reset => "reset",
6313                SubState::Active if complete => "complete",
6314                SubState::Active => "bootstrapping",
6315            };
6316            subscriptions.insert(
6317                sub.id.clone(),
6318                DiagnosticSubscription {
6319                    id: sub.id.clone(),
6320                    table: sub.table.clone(),
6321                    state: state.to_owned(),
6322                    complete,
6323                    cursor: Some(sub.cursor),
6324                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6325                },
6326            );
6327        }
6328        for expected in &request.expected_subscriptions {
6329            if expected.id.is_empty() || expected.table.is_empty() {
6330                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6331            }
6332            if subscriptions
6333                .get(&expected.id)
6334                .is_some_and(|registered| registered.table != expected.table)
6335            {
6336                subscriptions.insert(
6337                    expected.id.clone(),
6338                    DiagnosticSubscription {
6339                        id: expected.id.clone(),
6340                        table: expected.table.clone(),
6341                        state: "failed".to_owned(),
6342                        complete: false,
6343                        cursor: None,
6344                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6345                    },
6346                );
6347            } else {
6348                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6349                    DiagnosticSubscription {
6350                        id: expected.id.clone(),
6351                        table: expected.table.clone(),
6352                        state: "unregistered".to_owned(),
6353                        complete: false,
6354                        cursor: None,
6355                        reason_code: None,
6356                    }
6357                });
6358            }
6359        }
6360        let mut ordered_subscriptions = Vec::new();
6361        let mut included = BTreeSet::new();
6362        for expected in &request.expected_subscriptions {
6363            if included.insert(expected.id.clone()) {
6364                if let Some(subscription) = subscriptions.get(&expected.id) {
6365                    ordered_subscriptions.push(subscription.clone());
6366                }
6367            }
6368        }
6369        for (id, subscription) in &subscriptions {
6370            if included.insert(id.clone()) {
6371                ordered_subscriptions.push(subscription.clone());
6372            }
6373        }
6374        let subscriptions_truncated =
6375            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6376        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6377        let captured_at_ms = self.clock_now_ms();
6378        let lease = if let Some(error_code) = self
6379            .lease_state
6380            .as_ref()
6381            .and_then(|state| state.error_code.clone())
6382        {
6383            ClientDiagnosticsLease {
6384                state: "stopped".to_owned(),
6385                expires_at_ms: self
6386                    .lease_state
6387                    .as_ref()
6388                    .and_then(|state| state.expires_at_ms),
6389                error_code: Some(Self::diagnostic_code(&error_code)),
6390            }
6391        } else if let Some(expires_at_ms) = self
6392            .lease_state
6393            .as_ref()
6394            .and_then(|state| state.expires_at_ms)
6395        {
6396            ClientDiagnosticsLease {
6397                state: if expires_at_ms <= captured_at_ms {
6398                    "expired".to_owned()
6399                } else {
6400                    "active".to_owned()
6401                },
6402                expires_at_ms: Some(expires_at_ms),
6403                error_code: None,
6404            }
6405        } else {
6406            ClientDiagnosticsLease {
6407                state: "none".to_owned(),
6408                expires_at_ms: None,
6409                error_code: None,
6410            }
6411        };
6412        let connectivity = match self.last_round.as_ref() {
6413            Some(round) if round.status == "succeeded" => "online",
6414            Some(round)
6415                if round.status == "failed"
6416                    && round
6417                        .error_code
6418                        .as_deref()
6419                        .is_some_and(Self::retryable_transport_code) =>
6420            {
6421                "offline"
6422            }
6423            _ => "unknown",
6424        };
6425        Ok(ClientDiagnosticsSnapshot {
6426            version: CLIENT_DIAGNOSTICS_VERSION,
6427            captured_at_ms,
6428            host: ClientDiagnosticsHost {
6429                kind: "direct".to_owned(),
6430                role: "single".to_owned(),
6431                connectivity: connectivity.to_owned(),
6432                realtime: self.realtime_state,
6433                realtime_policy: self.realtime_policy,
6434                realtime_reason_code: self.realtime_reason_code.clone(),
6435                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6436            },
6437            security_lifecycle: self.security_lifecycle().to_owned(),
6438            schema: ClientDiagnosticsSchema {
6439                current_version: self.schema.version,
6440                upgrading: self.upgrading,
6441                required_version: self
6442                    .schema_floor
6443                    .as_ref()
6444                    .and_then(|floor| floor.required_schema_version),
6445                latest_version: self
6446                    .schema_floor
6447                    .as_ref()
6448                    .and_then(|floor| floor.latest_schema_version),
6449            },
6450            replica: ClientDiagnosticsReplica {
6451                local_revision: self.local_revision().to_string(),
6452                sync_needed: self.sync_needed,
6453                pending_outbox: self.outbox.len(),
6454            },
6455            lease,
6456            subscriptions: ordered_subscriptions,
6457            subscriptions_truncated,
6458            last_round: self.last_round.clone(),
6459            last_change: self.last_change.clone(),
6460            storage: self.diagnostics_storage(),
6461            query_failures: self.query_failures.clone(),
6462        })
6463    }
6464
6465    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6466        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6467            let page_count: i64 = self
6468                .conn
6469                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6470            let page_size: i64 = self
6471                .conn
6472                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6473            // Reuse compiled aggregates; every invocation still reads current storage.
6474            let outbox_bytes: i64 = self
6475                .conn
6476                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6477                .query_row([], |row| row.get(0))?;
6478            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6479                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6480            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6481            let blob_bytes = if self.schema_has_blobs() {
6482                self.conn
6483                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6484                    .query_row([], |row| row.get(0))?
6485            } else {
6486                0
6487            };
6488            let pressure = self
6489                .limits
6490                .blob_cache_max_bytes
6491                .is_some_and(|limit| blob_bytes > limit);
6492            Ok(ClientDiagnosticsStorage {
6493                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6494                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6495                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6496                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6497                retained_outcome_entries: Some(outcome_entries.max(0)),
6498                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6499                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6500            })
6501        };
6502        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6503            status: "unreadable".to_owned(),
6504            database_bytes_approx: None,
6505            pending_outbox_bytes_approx: None,
6506            retained_outcome_bytes_approx: None,
6507            retained_outcome_entries: None,
6508            blob_cache_bytes_approx: None,
6509            pressure_reason_code: None,
6510        })
6511    }
6512
6513    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6514        self.change_queue.drain(..).collect()
6515    }
6516
6517    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6518        self.sync_intent_queue.drain(..).collect()
6519    }
6520
6521    fn schedule_background_retry(&mut self) -> u64 {
6522        let delay_ms = self.retry_delay_ms;
6523        self.sync_intent_queue
6524            .push_back(SyncIntent::Background { delay_ms });
6525        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6526        self.round_retry_delay_ms = Some(delay_ms);
6527        delay_ms
6528    }
6529
6530    fn reset_background_retry(&mut self) {
6531        self.retry_delay_ms = 250;
6532    }
6533
6534    fn retryable_transport_code(code: &str) -> bool {
6535        code == "transport.failed"
6536            || code == "transport.unavailable"
6537            || code == "sync.transport_failed"
6538    }
6539
6540    /// A failed round worth a background retry: a transport failure, or a
6541    /// §10.2 code the catalog marks retryable (clients may hardcode the
6542    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
6543    /// on the server's `retryable` flag.
6544    fn retryable_failure_code(code: &str) -> bool {
6545        Self::retryable_transport_code(code)
6546            || matches!(
6547                code,
6548                "sync.auth_required"
6549                    | "sync.auth_lease_required"
6550                    | "sync.auth_lease_revoked"
6551                    | "sync.internal_error"
6552                    | "sync.idempotency_cache_miss"
6553                    | "sync.schema_not_ready"
6554                    | "sync.segment_expired"
6555                    | "sync.rate_limited"
6556                    | "sync.websocket_connection_limit"
6557            )
6558    }
6559
6560    fn diagnostic_code(code: &str) -> String {
6561        let valid = !code.is_empty()
6562            && code.len() <= 96
6563            && code.contains('.')
6564            && code
6565                .bytes()
6566                .next()
6567                .is_some_and(|byte| byte.is_ascii_lowercase())
6568            && code.bytes().all(|byte| {
6569                byte.is_ascii_lowercase()
6570                    || byte.is_ascii_digit()
6571                    || matches!(byte, b'.' | b'_' | b'-')
6572            });
6573        if valid {
6574            code.to_owned()
6575        } else {
6576            "client.unknown_failure".to_owned()
6577        }
6578    }
6579
6580    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6581        if self.sync_needed != value {
6582            if self.begin_observation("syncular_status").is_ok() {
6583                self.sync_needed = value;
6584                let batch = ChangeAccumulator {
6585                    status: true,
6586                    ..ChangeAccumulator::default()
6587                };
6588                if self.finish_observation("syncular_status", batch).is_err() {
6589                    self.rollback_observation("syncular_status");
6590                }
6591            } else {
6592                self.sync_needed = value;
6593            }
6594        }
6595        if value && interactive {
6596            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6597        }
6598    }
6599
6600    fn sqlite_failure(
6601        storage_failure: &RefCell<Option<QueryReadFailure>>,
6602        error: rusqlite::Error,
6603    ) -> String {
6604        let message = error.to_string();
6605        let failure = QueryReadFailure::from(error);
6606        let code = failure.code;
6607        if code.is_some() && storage_failure.borrow().is_none() {
6608            *storage_failure.borrow_mut() = Some(failure);
6609        }
6610        match code {
6611            Some(code) => format!("{code}: {message}"),
6612            None => message,
6613        }
6614    }
6615
6616    fn begin_observation(&self, name: &str) -> Result<(), String> {
6617        self.conn
6618            .execute_batch(&format!("SAVEPOINT {name}"))
6619            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
6620    }
6621
6622    fn rollback_observation(&self, name: &str) {
6623        if let Err(error) = self
6624            .conn
6625            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
6626        {
6627            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
6628                if failure.rollback_failure.is_none() {
6629                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
6630                }
6631            }
6632        }
6633    }
6634
6635    fn finish_observation(
6636        &mut self,
6637        name: &str,
6638        mut batch: ChangeAccumulator,
6639    ) -> Result<(), String> {
6640        #[cfg(feature = "bench-internals")]
6641        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6642        if self.failed_commits.iter().any(|(commit, _)| {
6643            commit
6644                .ops
6645                .iter()
6646                .any(|operation| batch.tables.contains_key(&operation.table))
6647        }) {
6648            batch.outcomes = true;
6649        }
6650        if !batch.touched() {
6651            self.conn
6652                .execute_batch(&format!("RELEASE {name}"))
6653                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6654            return Ok(());
6655        }
6656        let revision = self
6657            .local_revision()
6658            .checked_add(1)
6659            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6660        self.conn
6661            .execute(
6662                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6663                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6664            )
6665            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6666        let status = batch.status.then(|| self.status_snapshot());
6667        let event = ClientChangeBatch {
6668            revision: revision.to_string(),
6669            tables: batch
6670                .tables
6671                .into_iter()
6672                .map(|(table, scope_keys)| TableChange {
6673                    table,
6674                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6675                })
6676                .collect(),
6677            windows: batch
6678                .windows
6679                .into_iter()
6680                .map(|((base_key, table), units)| WindowChange {
6681                    base_key,
6682                    table,
6683                    units: units.into_iter().collect(),
6684                })
6685                .collect(),
6686            status,
6687            conflicts_changed: batch.conflicts,
6688            rejections_changed: batch.rejections,
6689            outcomes_changed: batch.outcomes,
6690        };
6691        self.conn
6692            .execute_batch(&format!("RELEASE {name}"))
6693            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6694        let mut diagnostic_tables = event
6695            .tables
6696            .iter()
6697            .map(|entry| entry.table.clone())
6698            .collect::<BTreeSet<_>>()
6699            .into_iter()
6700            .collect::<Vec<_>>();
6701        let mut diagnostic_windows = event
6702            .windows
6703            .iter()
6704            .map(|entry| entry.table.clone())
6705            .collect::<BTreeSet<_>>()
6706            .into_iter()
6707            .collect::<Vec<_>>();
6708        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6709            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6710        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6711        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6712        self.last_change = Some(DiagnosticLastChange {
6713            revision: event.revision.clone(),
6714            recorded_at_ms: self.clock_now_ms(),
6715            tables: diagnostic_tables,
6716            windows: diagnostic_windows,
6717            domains_truncated,
6718            status_changed: event.status.is_some(),
6719            conflicts_changed: event.conflicts_changed,
6720            rejections_changed: event.rejections_changed,
6721            outcomes_changed: event.outcomes_changed,
6722        });
6723        self.change_queue.push_back(event);
6724        Ok(())
6725    }
6726
6727    fn record_scope_map(
6728        &self,
6729        batch: &mut ChangeAccumulator,
6730        table_name: &str,
6731        scopes: &[(String, Vec<String>)],
6732    ) {
6733        let Some(table) = self.schema.table(table_name) else {
6734            return;
6735        };
6736        for (variable, values) in scopes {
6737            let Some(scope) = table
6738                .scope_variables
6739                .iter()
6740                .find(|scope| &scope.variable == variable)
6741            else {
6742                continue;
6743            };
6744            for value in values {
6745                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6746            }
6747        }
6748    }
6749
6750    /// Record a row's current scope keys from the base or visible table.
6751    fn record_row_scopes(
6752        &self,
6753        batch: &mut ChangeAccumulator,
6754        table_name: &str,
6755        row_id: &str,
6756        base: bool,
6757    ) -> bool {
6758        let Some(table) = self.schema.table(table_name) else {
6759            return false;
6760        };
6761        if table.scope_variables.is_empty() {
6762            return false;
6763        }
6764        let columns = table
6765            .scope_variables
6766            .iter()
6767            .map(|scope| quote_ident(&scope.column))
6768            .collect::<Vec<_>>()
6769            .join(", ");
6770        let full_table = if base {
6771            base_table(table_name)
6772        } else {
6773            visible_table(table_name)
6774        };
6775        let sql = format!(
6776            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6777            row_id_predicate(table)
6778        );
6779        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6780            return false;
6781        };
6782        let values = stmt.query_row(rusqlite::params![row_id], |row| {
6783            let mut values = Vec::with_capacity(table.scope_variables.len());
6784            for index in 0..table.scope_variables.len() {
6785                values.push(row.get::<_, Option<String>>(index)?);
6786            }
6787            Ok(values)
6788        });
6789        let Ok(values) = values else {
6790            return false;
6791        };
6792        let mut recorded = false;
6793        for (scope, value) in table.scope_variables.iter().zip(values) {
6794            if let Some(value) = value {
6795                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6796                recorded = true;
6797            }
6798        }
6799        recorded
6800    }
6801
6802    fn record_commit_changes(
6803        &self,
6804        batch: &mut ChangeAccumulator,
6805        tables: &[String],
6806        changes: &[ssp2::model::Change],
6807    ) {
6808        #[cfg(feature = "bench-internals")]
6809        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6810        for change in changes {
6811            let Some(table_name) = tables.get(change.table_index as usize) else {
6812                continue;
6813            };
6814            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6815            if let Some(table) = self.schema.table(table_name) {
6816                for (variable, value) in &change.scopes {
6817                    if let Some(scope) = table
6818                        .scope_variables
6819                        .iter()
6820                        .find(|scope| &scope.variable == variable)
6821                    {
6822                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
6823                        precise = true;
6824                    }
6825                }
6826            }
6827            if !precise {
6828                batch.table(table_name);
6829            }
6830        }
6831    }
6832
6833    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6834        if effective.is_empty() {
6835            return false;
6836        }
6837        let Some(table) = self.schema.table(table_name) else {
6838            return false;
6839        };
6840        let mut clauses = Vec::new();
6841        let mut params = Vec::new();
6842        for (variable, values) in effective {
6843            let Some(column) = table.scope_column(variable) else {
6844                return false;
6845            };
6846            if values.is_empty() {
6847                return false;
6848            }
6849            let placeholders = values
6850                .iter()
6851                .map(|value| {
6852                    params.push(SqlValue::Text(value.clone()));
6853                    "?"
6854                })
6855                .collect::<Vec<_>>()
6856                .join(", ");
6857            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6858        }
6859        let sql = format!(
6860            "SELECT 1 FROM {} WHERE {} LIMIT 1",
6861            base_table(table_name),
6862            clauses.join(" AND ")
6863        );
6864        self.conn
6865            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6866            .is_ok()
6867    }
6868
6869    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
6870    pub fn upgrading(&self) -> bool {
6871        self.upgrading
6872    }
6873
6874    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
6875    /// keeping this client's local database (identity, outbox, tables). The
6876    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
6877    /// version changed. Mirrors the TS client's boot-time detection —
6878    /// the Rust core has no persistent restart, so recreation IS the boot.
6879    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6880        let new_schema = parse_schema_json(schema_json)?;
6881        let marker: Option<i32> = self
6882            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6883            .and_then(|v| v.parse().ok());
6884        self.schema = new_schema;
6885        self.create_bookkeeping_tables()?;
6886        if marker != Some(self.schema.version) {
6887            self.run_schema_reset()?;
6888        }
6889        self.prune_unknown_subscriptions()?;
6890        // The conformance recreate is the in-memory equivalent of reopening a
6891        // durable client. Apply the same startup catch-up contract even when
6892        // the schema itself did not change.
6893        self.enqueue_startup_sync_if_needed()?;
6894        Ok(())
6895    }
6896
6897    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6898        let startup_work = !self.stopped
6899            && (!self.outbox.is_empty()
6900                || !self.load_pending_evictions()?.is_empty()
6901                || self.subs.iter().any(|sub| sub.state == SubState::Active));
6902        if startup_work {
6903            self.sync_needed = true;
6904            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6905        }
6906        Ok(())
6907    }
6908
6909    /// A native client persists schema-floor stops across process restarts.
6910    /// Once the running app already satisfies that floor, the persisted stop
6911    /// is only stale evidence from an older server round (for example, the
6912    /// server was restarted after catching up to an app that was ahead).
6913    /// Clear it and let the normal startup pull re-negotiate. If the server is
6914    /// still incompatible it will return the floor again in that first round.
6915    fn clear_satisfied_persisted_schema_floor(&mut self) {
6916        let satisfied = self
6917            .schema_floor
6918            .as_ref()
6919            .and_then(|floor| floor.required_schema_version)
6920            .is_some_and(|required| self.schema.version >= required);
6921        if !satisfied {
6922            return;
6923        }
6924        self.schema_floor = None;
6925        self.stopped = false;
6926        self.delete_meta(SCHEMA_FLOOR_KEY);
6927    }
6928
6929    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
6930    /// path already swept it; this covers a same-version or fresh-install open,
6931    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
6932    /// an aware binary can apply.
6933    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6934        let Some(replica_path) = self.previous_version_replica_path() else {
6935            return Ok(());
6936        };
6937        let max_age_ms = self
6938            .previous_version
6939            .filter(|config| config.enabled)
6940            .map(|config| config.max_age_ms);
6941        reconcile_previous_version_at_boot(
6942            &self.conn,
6943            &replica_path,
6944            self.schema.version,
6945            max_age_ms,
6946            self.clock_now_ms(),
6947        )
6948    }
6949
6950    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
6951    /// no file — an in-memory replica returns an empty path, and a `file:` URI
6952    /// has no plain sibling path — so the feature resolves as off there.
6953    fn previous_version_replica_path(&self) -> Option<String> {
6954        let path = self.conn.path()?;
6955        if path.is_empty() || path.starts_with("file:") {
6956            return None;
6957        }
6958        Some(path.to_owned())
6959    }
6960
6961    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
6962    /// and leaseState. Drops/recreates every synced table from the new
6963    /// schema, resets subscription sync-state (keeping registrations), clears
6964    /// the schema-floor stop state, rewrites the marker, drops outbox commits
6965    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
6966    fn run_schema_reset(&mut self) -> Result<(), String> {
6967        self.begin_observation("syncular_schema_reset")?;
6968        let mut batch = ChangeAccumulator::default();
6969        let result = self.run_schema_reset_observed(&mut batch, true, true);
6970        if let Err(error) = result {
6971            self.rollback_observation("syncular_schema_reset");
6972            return Err(error);
6973        }
6974        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6975            self.rollback_observation("syncular_schema_reset");
6976            return Err(error);
6977        }
6978        Ok(())
6979    }
6980
6981    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6982        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6983        self.begin_observation("syncular_log_epoch_reset")?;
6984        let mut batch = ChangeAccumulator::default();
6985        let result = self.run_schema_reset_observed(&mut batch, false, false);
6986        if let Err(error) = result {
6987            self.rollback_observation("syncular_log_epoch_reset");
6988            return Err(error);
6989        }
6990        self.set_meta(LOG_EPOCH_KEY, log_epoch);
6991        self.sync_needed = true;
6992        batch.status = true;
6993        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6994            self.rollback_observation("syncular_log_epoch_reset");
6995            return Err(error);
6996        }
6997        self.sync_intent_queue.push_back(SyncIntent::Interactive);
6998        Ok(resets)
6999    }
7000
7001    fn run_schema_reset_observed(
7002        &mut self,
7003        batch: &mut ChangeAccumulator,
7004        drop_incompatible: bool,
7005        capture: bool,
7006    ) -> Result<(), String> {
7007        self.upgrading = true;
7008        batch.status = true;
7009        // RFC 0005 D6: classify the pending outbox against the NEW compiled
7010        // schema BEFORE the wipe, and record the advisory audit. It drops
7011        // nothing — the §7.4.4 send-time drop stays the only path that removes
7012        // a commit. The classification is shared with that drop so the two can
7013        // never disagree about what is incompatible.
7014        if self.previous_version.is_some_and(|config| config.enabled) {
7015            let previous_version = self
7016                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7017                .and_then(|value| value.parse::<i32>().ok())
7018                .unwrap_or(self.schema.version);
7019            let pending = self
7020                .outbox
7021                .iter()
7022                .map(|commit| PendingCommitAudit {
7023                    commit_id: &commit.client_commit_id,
7024                    operations: commit_audit_operations(commit),
7025                })
7026                .collect::<Vec<_>>();
7027            crate::previous_version::write_previous_version_audit(
7028                &self.conn,
7029                &crate::previous_version::build_previous_version_audit(
7030                    &self.schema,
7031                    &pending,
7032                    previous_version,
7033                    self.schema.version,
7034                    self.clock_now_ms(),
7035                ),
7036            );
7037        }
7038        // RFC 0005 D5: the container lives in its own FILE, so the reset can
7039        // never reach it. Sweep first (unconditional — also on the log-epoch
7040        // reset, which captures nothing), then capture BEFORE the wipe drops
7041        // any row. A refused capture records its reason and leaves no file.
7042        if let Some(replica_path) = self.previous_version_replica_path() {
7043            sweep_previous_version_container(&self.conn, &replica_path)?;
7044            if capture {
7045                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
7046                    let previous_version = self
7047                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7048                        .and_then(|value| value.parse::<i32>().ok())
7049                        .unwrap_or(self.schema.version);
7050                    capture_previous_version_from_replica(
7051                        &self.conn,
7052                        &replica_path,
7053                        previous_version,
7054                        self.schema.version,
7055                        &config,
7056                        self.clock_now_ms(),
7057                    )?;
7058                }
7059            }
7060        }
7061        for table in &self.schema.tables {
7062            batch.table(&table.name);
7063        }
7064        for (base_key, unit, table) in self.load_registered_window_units() {
7065            batch.window(&base_key, &table, &unit);
7066        }
7067        // The per-table insert SQL is derived from the OLD column lists.
7068        self.insert_sql.borrow_mut().clear();
7069        self.overlay_dirty.set(true);
7070        // Drop every synced table (base + visible) that currently exists —
7071        // discovered from sqlite_master so a bump that adds/removes tables is
7072        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
7073        // and the blob cache) are preserved; base tables are `_syncular_base_*`
7074        // so they are matched explicitly, not by the bookkeeping filter.
7075        // Drop virtual tables first so SQLite removes every FTS shadow table
7076        // atomically instead of the generic discovery tearing it apart.
7077        let virtual_tables: Vec<String> = {
7078            let mut stmt = self
7079                .conn
7080                .prepare(
7081                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
7082                )
7083                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7084            let rows = stmt
7085                .query_map([], |row| row.get::<_, String>(0))
7086                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7087            rows.filter_map(Result::ok)
7088                .filter(|name| is_synced_table_name(name))
7089                .collect()
7090        };
7091        for name in virtual_tables {
7092            self.conn
7093                .execute_batch(&format!(
7094                    "DROP TABLE IF EXISTS {}",
7095                    quote_ident(&format!("_syncular_fts_{name}"))
7096                ))
7097                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7098            self.conn
7099                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7100                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7101        }
7102        let existing: Vec<String> = {
7103            let mut stmt = self
7104                .conn
7105                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
7106                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7107            let rows = stmt
7108                .query_map([], |row| row.get::<_, String>(0))
7109                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7110            rows.filter_map(Result::ok)
7111                .filter(|name| is_synced_table_name(name))
7112                .collect()
7113        };
7114        for name in &existing {
7115            if let Some(table) = name.strip_prefix("_syncular_base_") {
7116                batch.table(table);
7117            } else if !name.starts_with("_syncular_") {
7118                batch.table(name);
7119            }
7120        }
7121        for name in existing {
7122            self.conn
7123                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7124                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7125        }
7126        // Recreate the synced tables from the NEW schema.
7127        self.create_synced_tables()?;
7128        // Reset every subscription's sync-state, keeping the registration.
7129        for sub in &mut self.subs {
7130            sub.cursor = -1;
7131            sub.bootstrap_state = None;
7132            sub.effective = None;
7133            sub.state = SubState::Active;
7134            sub.reason_code = None;
7135            sub.synced_once = false;
7136        }
7137        let subs = self.subs.clone();
7138        for sub in &subs {
7139            self.persist_sub(sub)?;
7140        }
7141        // The stop state is over: this client now ships a servable schema.
7142        self.stopped = false;
7143        self.schema_floor = None;
7144        self.delete_meta(SCHEMA_FLOOR_KEY);
7145        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
7146        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
7147        // D1: the descriptor follows every marker write. A crash in between
7148        // self-heals on the next same-version open, which backfills it.
7149        set_local_schema_descriptor(&self.conn, &self.schema);
7150        // §7.4.4: drop outbox commits that cannot re-encode under the new
7151        // schema (a referenced column/table the bump removed), surfacing each
7152        // as a `sync.outbox_incompatible` rejection.
7153        if drop_incompatible && self.drop_incompatible_outbox()? {
7154            batch.rejections = true;
7155            batch.status = true;
7156            batch.outcomes = true;
7157        }
7158        // Re-apply the surviving outbox optimistically over the empty tables.
7159        self.rebuild_overlay();
7160        Ok(())
7161    }
7162
7163    /// §7.4.4: a persisted upsert whose values reference a column the current
7164    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
7165    /// and raise a client-local `sync.outbox_incompatible` rejection.
7166    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
7167        let incompatible = self
7168            .outbox
7169            .iter()
7170            .filter(|commit| {
7171                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
7172            })
7173            .cloned()
7174            .collect::<Vec<_>>();
7175        if incompatible.is_empty() {
7176            return Ok(false);
7177        }
7178        let mut rejections = Vec::new();
7179        for commit in &incompatible {
7180            let results = commit
7181                .ops
7182                .iter()
7183                .enumerate()
7184                .map(|(op_index, operation)| {
7185                    let rejection = RejectionRecord {
7186                        client_commit_id: commit.client_commit_id.clone(),
7187                        op_index: op_index as i32,
7188                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
7189                        message: "the persisted commit cannot encode under the current schema"
7190                            .to_owned(),
7191                        retryable: false,
7192                        details: None,
7193                        operation: Some(CommitOperation::from(operation)),
7194                    };
7195                    rejections.push(rejection.clone());
7196                    CommitOperationOutcome::Error { rejection }
7197                })
7198                .collect::<Vec<_>>();
7199            self.persist_commit_outcome(
7200                &commit.client_commit_id,
7201                CommitOutcomeStatus::Rejected,
7202                &results,
7203                Some(&commit.ops),
7204            )?;
7205            self.delete_outbox_persisted(&commit.client_commit_id)?;
7206        }
7207        self.prune_commit_outcomes()?;
7208        let incompatible_ids = incompatible
7209            .iter()
7210            .map(|commit| commit.client_commit_id.as_str())
7211            .collect::<BTreeSet<_>>();
7212        self.outbox
7213            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7214        self.rejections.extend(rejections);
7215        Ok(true)
7216    }
7217
7218    /// §7.4.3: (re)create the base + visible table pair for every synced
7219    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
7220    fn create_synced_tables(&self) -> Result<(), String> {
7221        for table in &self.schema.tables {
7222            // The base half + the visible half form the synced-table pair. An
7223            // index name is global in SQLite, so the base half's indexes are
7224            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
7225            for (full, index_prefix) in [
7226                (base_table(&table.name), "_syncular_base_"),
7227                (visible_table(&table.name), ""),
7228            ] {
7229                let mut cols: Vec<String> =
7230                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7231                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7232                let sql = format!(
7233                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7234                    cols.join(", "),
7235                    quote_ident(&table.primary_key)
7236                );
7237                self.conn
7238                    .execute(&sql, [])
7239                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7240                // Local secondary indexes (CREATE INDEX subset). Created on
7241                // both halves so mirror reads hit an index on either. Runs on
7242                // both the initial create and the §7.4.3 reset recreate path.
7243                for index in &table.indexes {
7244                    let unique = if index.unique { "UNIQUE " } else { "" };
7245                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7246                    let cols_sql = index
7247                        .columns
7248                        .iter()
7249                        .map(|c| quote_ident(c))
7250                        .collect::<Vec<_>>()
7251                        .join(", ");
7252                    let index_sql = format!(
7253                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7254                    );
7255                    self.conn
7256                        .execute(&index_sql, [])
7257                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7258                }
7259            }
7260        }
7261        // FTS exists only on the visible half. It is a local search
7262        // projection, never a synced/base table or a wire-schema member.
7263        for table in &self.schema.tables {
7264            for index in &table.fts_indexes {
7265                self.create_fts_projection(table, index)?;
7266            }
7267        }
7268        Ok(())
7269    }
7270
7271    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7272        let count: i64 = self
7273            .conn
7274            .query_row(
7275                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7276                rusqlite::params![index.name],
7277                |row| row.get(0),
7278            )
7279            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7280        Ok(count > 0)
7281    }
7282
7283    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7284        for suffix in ["ai", "ad", "au"] {
7285            self.conn
7286                .execute(
7287                    &format!(
7288                        "DROP TRIGGER IF EXISTS {}",
7289                        quote_ident(&format!("{}_{suffix}", index.name))
7290                    ),
7291                    [],
7292                )
7293                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7294        }
7295        Ok(())
7296    }
7297
7298    fn create_fts_triggers(
7299        &self,
7300        table: &TableSchema,
7301        index: &FtsIndexSchema,
7302    ) -> Result<(), String> {
7303        let fts = quote_ident(&index.name);
7304        let source = visible_table(&table.name);
7305        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7306        let pk = quote_ident(&table.primary_key);
7307        let projection_columns = std::iter::once(source_id.clone())
7308            .chain(index.columns.iter().map(|column| quote_ident(column)))
7309            .collect::<Vec<_>>()
7310            .join(", ");
7311        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7312            .chain(
7313                index
7314                    .columns
7315                    .iter()
7316                    .map(|column| format!("new.{}", quote_ident(column))),
7317            )
7318            .collect::<Vec<_>>()
7319            .join(", ");
7320        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7321        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)");
7322        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)");
7323        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})");
7324        let bi = quote_ident(&format!("{}_bi", index.name));
7325        let ai = quote_ident(&format!("{}_ai", index.name));
7326        let ad = quote_ident(&format!("{}_ad", index.name));
7327        let au = quote_ident(&format!("{}_au", index.name));
7328        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7329        let sql = format!(
7330            "DROP TRIGGER IF EXISTS {bi};
7331             DROP TRIGGER IF EXISTS {ai};
7332             DROP TRIGGER IF EXISTS {ad};
7333             DROP TRIGGER IF EXISTS {au};
7334             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7335             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7336             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7337             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7338        );
7339        self.conn
7340            .execute_batch(&sql)
7341            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7342    }
7343
7344    fn rebuild_fts_projection(
7345        &self,
7346        table: &TableSchema,
7347        index: &FtsIndexSchema,
7348    ) -> Result<(), String> {
7349        let fts = quote_ident(&index.name);
7350        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7351        let pk = quote_ident(&table.primary_key);
7352        let indexed_columns = index
7353            .columns
7354            .iter()
7355            .map(|column| quote_ident(column))
7356            .collect::<Vec<_>>();
7357        let projection_columns = std::iter::once(source_id.clone())
7358            .chain(indexed_columns.iter().cloned())
7359            .collect::<Vec<_>>()
7360            .join(", ");
7361        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7362        let sql = format!(
7363            "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};",
7364            columns = indexed_columns.join(", "),
7365            source = visible_table(&table.name),
7366        );
7367        self.conn
7368            .execute_batch(&sql)
7369            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7370    }
7371
7372    fn create_fts_projection(
7373        &self,
7374        table: &TableSchema,
7375        index: &FtsIndexSchema,
7376    ) -> Result<(), String> {
7377        self.begin_observation("syncular_fts_schema")?;
7378        let result = (|| {
7379            let existed = self.fts_projection_exists(index)?;
7380            let tokenizer = index.tokenize.replace('\'', "''");
7381            let columns = index
7382                .columns
7383                .iter()
7384                .map(|column| quote_ident(column))
7385                .collect::<Vec<_>>()
7386                .join(", ");
7387            let sql = format!(
7388            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7389            fts = quote_ident(&index.name),
7390            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7391        );
7392            self.conn.execute(&sql, []).map_err(|error| {
7393                format!(
7394                    "cannot create local FTS5 projection {:?}: {error}",
7395                    index.name
7396                )
7397            })?;
7398            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7399            let mapped: bool = self
7400                .conn
7401                .query_row(
7402                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7403                    [format!("_syncular_fts_{}", index.name)],
7404                    |row| row.get(0),
7405                )
7406                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7407            self.conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS {mapping}(id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)")).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7408            if !mapped && existed {
7409                self.conn
7410                    .execute_batch(&format!(
7411                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7412                        quote_ident(FTS_SOURCE_ID_COLUMN),
7413                        quote_ident(&index.name)
7414                    ))
7415                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7416            }
7417            self.create_fts_triggers(table, index)?;
7418            if !existed {
7419                self.rebuild_fts_projection(table, index)?;
7420            }
7421            Ok(())
7422        })();
7423        if let Err(error) = result {
7424            self.rollback_observation("syncular_fts_schema");
7425            return Err(error);
7426        }
7427        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7428            self.rollback_observation("syncular_fts_schema");
7429            return Err(error.to_string());
7430        }
7431        Ok(())
7432    }
7433
7434    // -- persistence write-through --------------------------------------------
7435
7436    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7437        let state = serde_json::json!({
7438            "requested": scope_map_to_json(&sub.requested),
7439            "params": sub.params,
7440            "cursor": sub.cursor,
7441            "bootstrapState": sub.bootstrap_state,
7442            "status": sub.state.name(),
7443            "reasonCode": sub.reason_code,
7444            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7445            "syncedOnce": sub.synced_once,
7446        });
7447        self.conn.execute(
7448            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7449            rusqlite::params![sub.id, sub.table, state.to_string()],
7450        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7451    }
7452
7453    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7454        let ops: Vec<Value> = commit
7455            .ops
7456            .iter()
7457            .map(|op| {
7458                serde_json::json!({
7459                    "op": if op.upsert { "upsert" } else { "delete" },
7460                    "table": op.table,
7461                    "rowId": op.row_id,
7462                    "baseVersion": op.base_version,
7463                    "values": op.values.clone().map(Value::Object),
7464                })
7465            })
7466            .collect();
7467        self.conn
7468            .execute(
7469                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7470                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7471            )
7472            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7473        for operation in &commit.ops {
7474            let Some(values) = &operation.values else {
7475                continue;
7476            };
7477            let Some(table) = self.schema.table(&operation.table) else {
7478                continue;
7479            };
7480            for column in table
7481                .columns
7482                .iter()
7483                .filter(|column| column.ty == ColumnType::BlobRef)
7484            {
7485                let Some(Value::String(raw)) = values.get(&column.name) else {
7486                    continue;
7487                };
7488                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7489                    continue;
7490                };
7491                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7492                    continue;
7493                };
7494                self.conn
7495                    .execute(
7496                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7497                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7498                        rusqlite::params![commit.client_commit_id, blob_id],
7499                    )
7500                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7501            }
7502        }
7503        Ok(())
7504    }
7505
7506    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7507        self.conn
7508            .execute(
7509                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7510                rusqlite::params![client_commit_id],
7511            )
7512            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7513        self.conn
7514            .execute(
7515                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7516                rusqlite::params![client_commit_id],
7517            )
7518            .map(|_| ())
7519            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7520    }
7521
7522    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7523        match status {
7524            CommitOutcomeStatus::Applied => "applied",
7525            CommitOutcomeStatus::Cached => "cached",
7526            CommitOutcomeStatus::Conflict => "conflict",
7527            CommitOutcomeStatus::Rejected => "rejected",
7528        }
7529    }
7530
7531    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7532        match resolution {
7533            CommitOutcomeResolution::Active => "active",
7534            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7535            CommitOutcomeResolution::Superseded => "superseded",
7536            CommitOutcomeResolution::Dismissed => "dismissed",
7537        }
7538    }
7539
7540    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7541        match value {
7542            "applied" => Ok(CommitOutcomeStatus::Applied),
7543            "cached" => Ok(CommitOutcomeStatus::Cached),
7544            "conflict" => Ok(CommitOutcomeStatus::Conflict),
7545            "rejected" => Ok(CommitOutcomeStatus::Rejected),
7546            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7547        }
7548    }
7549
7550    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7551        match value {
7552            "active" => Ok(CommitOutcomeResolution::Active),
7553            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7554            "superseded" => Ok(CommitOutcomeResolution::Superseded),
7555            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7556            _ => Err(format!(
7557                "invalid persisted commit outcome resolution {value:?}"
7558            )),
7559        }
7560    }
7561
7562    fn persist_commit_outcome(
7563        &self,
7564        client_commit_id: &str,
7565        status: CommitOutcomeStatus,
7566        results: &[CommitOperationOutcome],
7567        operations: Option<&[OutboxOp]>,
7568    ) -> Result<(), String> {
7569        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7570        let operations_json = operations
7571            .map(|items| {
7572                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7573            })
7574            .transpose()
7575            .map_err(|error| error.to_string())?;
7576        self.conn
7577            .execute(
7578                "INSERT INTO _syncular_commit_outcomes (
7579                   client_commit_id, status, recorded_at_ms, results_json,
7580                   operations_json, resolution
7581                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7582                rusqlite::params![
7583                    client_commit_id,
7584                    Self::outcome_status_name(status),
7585                    self.clock_now_ms(),
7586                    results_json,
7587                    operations_json
7588                ],
7589            )
7590            .map(|_| ())
7591            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7592    }
7593
7594    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7595        let StoredCommitOutcomeRow {
7596            sequence,
7597            client_commit_id,
7598            status,
7599            recorded_at_ms,
7600            results_json,
7601            operations_json,
7602            resolution,
7603            resolved_at_ms,
7604            replacement_client_commit_id,
7605        } = row;
7606        Ok(CommitOutcome {
7607            sequence,
7608            client_commit_id,
7609            status: Self::parse_outcome_status(&status)?,
7610            recorded_at_ms,
7611            results: serde_json::from_str(&results_json)
7612                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7613            operations: operations_json
7614                .map(|value| {
7615                    serde_json::from_str(&value).map_err(|error| {
7616                        format!("invalid persisted commit outcome operations: {error}")
7617                    })
7618                })
7619                .transpose()?,
7620            retained_rows: None,
7621            resolution: Self::parse_outcome_resolution(&resolution)?,
7622            resolved_at_ms,
7623            replacement_client_commit_id,
7624        })
7625    }
7626
7627    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
7628        if let Some((commit, initial)) = self
7629            .failed_commits
7630            .iter()
7631            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
7632        {
7633            let mut rows = Vec::new();
7634            for operation in &commit.ops {
7635                let table = self
7636                    .schema
7637                    .table(&operation.table)
7638                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
7639                let key =
7640                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
7641                        .map_err(|_| "sync.local_corrupt".to_owned())?;
7642                let base = self.stored_row(table, &operation.row_id, true);
7643                rows.push(RetainedCommitRow {
7644                    table: operation.table.clone(),
7645                    row_id: operation.row_id.clone(),
7646                    local_row: if operation.upsert {
7647                        initial
7648                            .get(&key)
7649                            .and_then(Value::as_object)
7650                            .cloned()
7651                            .or_else(|| operation.values.clone())
7652                    } else {
7653                        None
7654                    },
7655                    server_row: base.as_ref().map(|(values, _)| {
7656                        table
7657                            .columns
7658                            .iter()
7659                            .zip(values)
7660                            .map(|(column, value)| {
7661                                (column.name.clone(), column_value_to_json(value))
7662                            })
7663                            .collect()
7664                    }),
7665                    server_version: base.map(|(_, version)| version),
7666                });
7667            }
7668            outcome.retained_rows = Some(rows);
7669        }
7670        Ok(outcome)
7671    }
7672
7673    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7674        let row = self
7675            .conn
7676            .query_row(
7677                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7678                        resolution, resolved_at_ms, replacement_client_commit_id
7679                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7680                rusqlite::params![client_commit_id],
7681                |row| {
7682                    Ok(StoredCommitOutcomeRow {
7683                        sequence: row.get(0)?,
7684                        client_commit_id: row.get(1)?,
7685                        status: row.get(2)?,
7686                        recorded_at_ms: row.get(3)?,
7687                        results_json: row.get(4)?,
7688                        operations_json: row.get(5)?,
7689                        resolution: row.get(6)?,
7690                        resolved_at_ms: row.get(7)?,
7691                        replacement_client_commit_id: row.get(8)?,
7692                    })
7693                },
7694            )
7695            .optional()
7696            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7697        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
7698            .transpose()
7699    }
7700
7701    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7702        if query.limit == Some(0) {
7703            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7704        }
7705        let mut sql = String::from(
7706            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7707                    resolution, resolved_at_ms, replacement_client_commit_id
7708               FROM _syncular_commit_outcomes",
7709        );
7710        if query.active_only {
7711            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7712        }
7713        sql.push_str(" ORDER BY seq DESC");
7714        if let Some(limit) = query.limit {
7715            sql.push_str(&format!(" LIMIT {limit}"));
7716        }
7717        let mut stmt = self
7718            .conn
7719            .prepare(&sql)
7720            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7721        let rows = stmt
7722            .query_map([], |row| {
7723                Ok(StoredCommitOutcomeRow {
7724                    sequence: row.get(0)?,
7725                    client_commit_id: row.get(1)?,
7726                    status: row.get(2)?,
7727                    recorded_at_ms: row.get(3)?,
7728                    results_json: row.get(4)?,
7729                    operations_json: row.get(5)?,
7730                    resolution: row.get(6)?,
7731                    resolved_at_ms: row.get(7)?,
7732                    replacement_client_commit_id: row.get(8)?,
7733                })
7734            })
7735            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7736        let mut outcomes = Vec::new();
7737        for row in rows {
7738            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
7739                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
7740            )?)?);
7741        }
7742        Ok(outcomes)
7743    }
7744
7745    fn prune_commit_outcomes(&self) -> Result<(), String> {
7746        #[cfg(test)]
7747        self.outcome_prune_count
7748            .set(self.outcome_prune_count.get() + 1);
7749        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7750        let count = self
7751            .conn
7752            .query_row(
7753                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7754                [],
7755                |row| row.get::<_, i64>(0),
7756            )
7757            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
7758            as usize;
7759        let excess = count.saturating_sub(max_entries);
7760        if excess == 0 {
7761            return Ok(());
7762        }
7763        let mut stmt = self
7764            .conn
7765            .prepare(
7766                "SELECT seq FROM _syncular_commit_outcomes
7767                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
7768                  ORDER BY seq ASC LIMIT ?1",
7769            )
7770            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7771        let rows = stmt
7772            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7773            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7774        let sequences = rows
7775            .collect::<Result<Vec<_>, _>>()
7776            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7777        drop(stmt);
7778        for sequence in sequences {
7779            self.conn
7780                .execute(
7781                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7782                    rusqlite::params![sequence],
7783                )
7784                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7785        }
7786        Ok(())
7787    }
7788
7789    // -- driver surface ---------------------------------------------------------
7790
7791    pub fn subscribe(
7792        &mut self,
7793        id: String,
7794        table: String,
7795        scopes: Vec<(String, Vec<String>)>,
7796        params: Option<String>,
7797    ) -> Result<(), String> {
7798        if self.schema.table(&table).is_none() {
7799            return Err(format!("unknown table {table:?}"));
7800        }
7801        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7802            let same_intent = existing.table == table
7803                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7804                && existing.params == params;
7805            if same_intent {
7806                return Ok(());
7807            }
7808            return Err(
7809                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7810                    .to_owned(),
7811            );
7812        }
7813        let sub = Subscription {
7814            id: id.clone(),
7815            table,
7816            requested: scopes,
7817            params,
7818            cursor: -1,
7819            bootstrap_state: None,
7820            state: SubState::Active,
7821            reason_code: None,
7822            effective: None,
7823            synced_once: false,
7824        };
7825        self.persist_sub(&sub)?;
7826        self.subs.push(sub);
7827        Ok(())
7828    }
7829
7830    pub fn unsubscribe(&mut self, id: &str) {
7831        self.subs.retain(|s| s.id != id);
7832        let _ = self.conn.execute(
7833            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7834            rusqlite::params![id],
7835        );
7836    }
7837
7838    // -- windowed subscriptions (§4.8) ------------------------------------------
7839
7840    /// §4.8: set the live window units for a base — a value-sharded family
7841    /// of subscriptions, one per unit. Added units get fresh subscriptions
7842    /// (image-lane bootstrap on the next sync); removed units are
7843    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
7844    /// Idempotent; re-entry cancels any deferred eviction.
7845    pub fn set_window(
7846        &mut self,
7847        base: &WindowBase,
7848        units: &[String],
7849    ) -> Result<CommandEffects, String> {
7850        let table = self
7851            .schema
7852            .table(&base.table)
7853            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7854        if table.scope_column(&base.variable).is_none() {
7855            return Err(format!(
7856                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7857                base.table, base.variable
7858            ));
7859        }
7860        let base_key = window_base_key(base);
7861        let wanted: std::collections::HashSet<&String> = units.iter().collect();
7862        let live = self.load_window_units(&base_key);
7863        self.begin_observation("syncular_window")?;
7864        let mut batch = ChangeAccumulator::default();
7865        let mut changed = false;
7866
7867        let prior_subs = self.subs.clone();
7868        let widened = (|| {
7869            // Widen: units wanted but not live → fresh subscription + registry row.
7870            for unit in units {
7871                if live.iter().any(|(u, _)| u == unit) {
7872                    continue;
7873                }
7874                let sub_id = derive_sub_id(base, unit);
7875                self.delete_pending_evict(&sub_id)?;
7876                self.insert_window_unit(&base_key, unit, &sub_id)?;
7877                self.subscribe(
7878                    sub_id,
7879                    base.table.clone(),
7880                    unit_scopes(base, unit),
7881                    base.params.clone(),
7882                )?;
7883                batch.window(&base_key, &base.table, unit);
7884                changed = true;
7885            }
7886
7887            self.finish_observation("syncular_window", batch)
7888        })();
7889        if let Err(error) = widened {
7890            self.rollback_observation("syncular_window");
7891            self.subs = prior_subs;
7892            return Err(error);
7893        }
7894        for (unit, sub_id) in live {
7895            if wanted.contains(&unit) {
7896                continue;
7897            }
7898            self.rebuild_overlay_if_dirty();
7899            let prior_subs = self.subs.clone();
7900            self.begin_observation("syncular_window")?;
7901            let effective = self
7902                .subs
7903                .iter()
7904                .find(|sub| sub.id == sub_id)
7905                .and_then(|sub| sub.effective.clone())
7906                .unwrap_or_else(|| unit_scopes(base, &unit));
7907            let mut batch = ChangeAccumulator::default();
7908            self.record_scope_map(&mut batch, &base.table, &effective);
7909            batch.window(&base_key, &base.table, &unit);
7910            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7911            let remaining = match result {
7912                Ok(remaining) => remaining,
7913                Err(error) => {
7914                    self.rollback_observation("syncular_window");
7915                    self.subs = prior_subs;
7916                    return Err(error);
7917                }
7918            };
7919            if let Err(error) = self.finish_observation("syncular_window", batch) {
7920                self.rollback_observation("syncular_window");
7921                self.subs = prior_subs;
7922                return Err(error);
7923            }
7924            if remaining {
7925                self.drain_pending_evictions()?;
7926            }
7927            changed = true;
7928        }
7929        Ok(if changed {
7930            CommandEffects::interactive()
7931        } else {
7932            CommandEffects::none()
7933        })
7934    }
7935
7936    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
7937    /// the subset still bootstrap-pending. Registration alone is not
7938    /// completeness — a unit is pending until its subscription completes a
7939    /// bootstrap round (cursor advances past -1 with no resume token held).
7940    pub fn window_state(&self, base: &WindowBase) -> WindowState {
7941        let mut units = Vec::new();
7942        let mut pending = Vec::new();
7943        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7944            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7945                Some(sub) => {
7946                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7947                }
7948                None => true,
7949            };
7950            if is_pending {
7951                pending.push(unit.clone());
7952            }
7953            units.push(unit);
7954        }
7955        WindowState { units, pending }
7956    }
7957
7958    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7959        let mut stmt = match self
7960            .conn
7961            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7962        {
7963            Ok(stmt) => stmt,
7964            Err(_) => return Vec::new(),
7965        };
7966        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7967            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7968        });
7969        match rows {
7970            Ok(rows) => rows.filter_map(Result::ok).collect(),
7971            Err(_) => Vec::new(),
7972        }
7973    }
7974
7975    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7976        let mut stmt = match self.conn.prepare(
7977            "SELECT windows.base, windows.unit, subscriptions.tbl
7978               FROM _syncular_windows AS windows
7979               JOIN _syncular_subscriptions AS subscriptions
7980                 ON subscriptions.id = windows.sub_id
7981               ORDER BY windows.base, windows.unit",
7982        ) {
7983            Ok(stmt) => stmt,
7984            Err(_) => return Vec::new(),
7985        };
7986        let rows = stmt.query_map([], |row| {
7987            Ok((
7988                row.get::<_, String>(0)?,
7989                row.get::<_, String>(1)?,
7990                row.get::<_, String>(2)?,
7991            ))
7992        });
7993        match rows {
7994            Ok(rows) => rows.filter_map(Result::ok).collect(),
7995            Err(_) => Vec::new(),
7996        }
7997    }
7998
7999    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
8000        self.conn
8001            .query_row(
8002                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
8003                rusqlite::params![sub_id],
8004                |row| Ok((row.get(0)?, row.get(1)?)),
8005            )
8006            .ok()
8007    }
8008
8009    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
8010        self.conn
8011            .execute(
8012                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
8013                rusqlite::params![base_key, unit, sub_id],
8014            )
8015            .map(|_| ())
8016            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8017    }
8018
8019    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
8020        self.conn
8021            .execute(
8022                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
8023                rusqlite::params![base_key, unit],
8024            )
8025            .map(|_| ())
8026            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8027    }
8028
8029    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
8030    /// Deletes the unit's rows except those pinned by a pending outbox
8031    /// commit (E1); records a deferred eviction if any pin remains; discards
8032    /// the subscription's cursor/resume/effective-echo (E3) and version
8033    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
8034    /// nothing.
8035    fn evict_unit(
8036        &mut self,
8037        base_key: &str,
8038        base: &WindowBase,
8039        unit: &str,
8040        sub_id: &str,
8041    ) -> Result<bool, String> {
8042        let effective = self
8043            .subs
8044            .iter()
8045            .find(|s| s.id == sub_id)
8046            .and_then(|s| s.effective.clone())
8047            .unwrap_or_else(|| unit_scopes(base, unit));
8048        let pinned = self.pinned_row_ids(&base.table);
8049        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
8050        self.delete_window_unit(base_key, unit)?;
8051        self.conn
8052            .execute(
8053                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8054                [sub_id],
8055            )
8056            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8057        self.subs.retain(|sub| sub.id != sub_id);
8058        if deferred {
8059            self.save_pending_evict(sub_id, &base.table, &effective)?;
8060        } else {
8061            self.delete_pending_evict(sub_id)?;
8062        }
8063        Ok(remaining)
8064    }
8065
8066    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
8067    /// primary keys. Returns (more unpinned work, any scoped rows remain).
8068    /// A missing scope-column mapping fails without deleting rows.
8069    fn evict_scope_rows(
8070        &self,
8071        table_name: &str,
8072        effective: &[(String, Vec<String>)],
8073        pinned: &std::collections::HashSet<String>,
8074    ) -> Result<(bool, bool), String> {
8075        if effective.is_empty() {
8076            return Ok((false, false));
8077        }
8078        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
8079        let mut clauses = Vec::new();
8080        let mut params: Vec<SqlValue> = Vec::new();
8081        for (variable, values) in effective {
8082            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
8083            if values.is_empty() {
8084                return Ok((false, false));
8085            }
8086            let holes = values
8087                .iter()
8088                .map(|v| {
8089                    params.push(SqlValue::Text(v.clone()));
8090                    "?"
8091                })
8092                .collect::<Vec<_>>()
8093                .join(", ");
8094            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
8095        }
8096        let scope = clauses.join(" AND ");
8097        let scope_params = params.len();
8098        let pk = quote_ident(&table.primary_key);
8099        if !pinned.is_empty() {
8100            let holes = pinned
8101                .iter()
8102                .map(|id| {
8103                    params.push(SqlValue::Text(id.clone()));
8104                    "?"
8105                })
8106                .collect::<Vec<_>>()
8107                .join(", ");
8108            clauses.push(format!("{pk} NOT IN ({holes})"));
8109        }
8110        let predicate = clauses.join(" AND ");
8111        let base = base_table(table_name);
8112        let visible = visible_table(table_name);
8113        let mut ids: Vec<SqlValue> = Vec::new();
8114        for source in [&base, &visible] {
8115            let mut stmt = self
8116                .conn
8117                .prepare_cached(&format!(
8118                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
8119                ))
8120                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8121            ids = stmt
8122                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
8123                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8124                .collect::<Result<_, _>>()
8125                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8126            if !ids.is_empty() {
8127                break;
8128            }
8129        }
8130        if !ids.is_empty() {
8131            let holes = vec!["?"; ids.len()].join(", ");
8132            for source in [&base, &visible] {
8133                self.conn
8134                    .execute(
8135                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
8136                        rusqlite::params_from_iter(&ids),
8137                    )
8138                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8139            }
8140        }
8141        let mut remaining = false;
8142        let mut deferred = false;
8143        for source in [&base, &visible] {
8144            remaining |= self
8145                .conn
8146                .query_row(
8147                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
8148                    rusqlite::params_from_iter(&params),
8149                    |row| row.get::<_, bool>(0),
8150                )
8151                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8152            deferred |= self
8153                .conn
8154                .query_row(
8155                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
8156                    rusqlite::params_from_iter(&params[..scope_params]),
8157                    |row| row.get::<_, bool>(0),
8158                )
8159                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8160        }
8161        Ok((remaining, deferred))
8162    }
8163
8164    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
8165    fn drain_pending_evictions(&mut self) -> Result<(), String> {
8166        let pending = self.load_pending_evictions()?;
8167        if pending.is_empty() {
8168            return Ok(());
8169        }
8170        self.rebuild_overlay_if_dirty();
8171        for (sub_id, table_name, effective) in pending {
8172            if self.schema.table(&table_name).is_none() {
8173                self.delete_pending_evict(&sub_id)?;
8174                continue;
8175            }
8176            loop {
8177                std::thread::yield_now();
8178                self.begin_observation("syncular_evict")?;
8179                let pinned = self.pinned_row_ids(&table_name);
8180                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
8181                let (remaining, deferred) = match result {
8182                    Ok(result) => result,
8183                    Err(error) => {
8184                        self.rollback_observation("syncular_evict");
8185                        return Err(error);
8186                    }
8187                };
8188                if !deferred {
8189                    if let Err(error) = self.delete_pending_evict(&sub_id) {
8190                        self.rollback_observation("syncular_evict");
8191                        return Err(error);
8192                    }
8193                }
8194                let mut batch = ChangeAccumulator::default();
8195                self.record_scope_map(&mut batch, &table_name, &effective);
8196                if let Err(error) = self.finish_observation("syncular_evict", batch) {
8197                    self.rollback_observation("syncular_evict");
8198                    return Err(error);
8199                }
8200                if !remaining {
8201                    break;
8202                }
8203            }
8204        }
8205        Ok(())
8206    }
8207
8208    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
8209    /// commit — rows that MUST NOT be evicted until the commit drains.
8210    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
8211        let mut pinned = std::collections::HashSet::new();
8212        for commit in self
8213            .outbox
8214            .iter()
8215            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
8216        {
8217            for op in &commit.ops {
8218                if op.table == table {
8219                    pinned.insert(op.row_id.clone());
8220                }
8221            }
8222        }
8223        pinned
8224    }
8225
8226    fn save_pending_evict(
8227        &self,
8228        sub_id: &str,
8229        table: &str,
8230        effective: &[(String, Vec<String>)],
8231    ) -> Result<(), String> {
8232        self.conn.execute(
8233            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
8234            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
8235        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8236    }
8237
8238    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
8239        self.conn
8240            .execute(
8241                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
8242                rusqlite::params![sub_id],
8243            )
8244            .map(|_| ())
8245            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8246    }
8247
8248    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
8249        let mut stmt = self
8250            .conn
8251            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
8252            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8253        let rows = stmt
8254            .query_map([], |row| {
8255                Ok((
8256                    row.get::<_, String>(0)?,
8257                    row.get::<_, String>(1)?,
8258                    row.get::<_, String>(2)?,
8259                ))
8260            })
8261            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8262        rows.map(|row| {
8263            let (sub_id, table, json) =
8264                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8265            let value: Value = serde_json::from_str(&json)
8266                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8267            let effective = json_to_scope_map(&value)
8268                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8269            Ok((sub_id, table, effective))
8270        })
8271        .collect()
8272    }
8273
8274    /// Select failed-intent retention; reload durable retained aggregates.
8275    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
8276        self.retain_failed_commits = enabled;
8277        self.failed_commits = self.load_failed_commits()?;
8278        self.overlay_dirty.set(true);
8279        Ok(())
8280    }
8281
8282    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
8283        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
8284            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8285        let rows = statement
8286            .query_map([], |row| {
8287                Ok((
8288                    row.get::<_, String>(0)?,
8289                    row.get::<_, String>(1)?,
8290                    row.get::<_, String>(2)?,
8291                ))
8292            })
8293            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8294        rows.map(|row| {
8295            let (id, operations, initial) =
8296                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8297            let operations: Vec<CommitOperation> =
8298                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
8299            let initial =
8300                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
8301            Ok((
8302                OutboxCommit {
8303                    client_commit_id: id,
8304                    ops: operations
8305                        .into_iter()
8306                        .map(|op| OutboxOp {
8307                            upsert: op.op == "upsert",
8308                            table: op.table,
8309                            row_id: op.row_id,
8310                            base_version: op.base_version,
8311                            values: op.values,
8312                        })
8313                        .collect(),
8314                },
8315                initial,
8316            ))
8317        })
8318        .collect()
8319    }
8320
8321    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
8322        let mut initial = Map::new();
8323        for operation in operations {
8324            let table = self
8325                .schema
8326                .table(&operation.table)
8327                .ok_or_else(|| "sync.unknown_table".to_owned())?;
8328            if let Some((values, _)) = self
8329                .stored_row(table, &operation.row_id, true)
8330                .or_else(|| self.visible_row(table, &operation.row_id))
8331            {
8332                let mut intended: Map<String, Value> = table
8333                    .columns
8334                    .iter()
8335                    .zip(values.iter())
8336                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8337                    .collect();
8338                if let Some(patch) = &operation.values {
8339                    intended.extend(patch.clone());
8340                }
8341                initial.insert(
8342                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8343                        .map_err(|_| "sync.local_corrupt".to_owned())?,
8344                    Value::Object(intended),
8345                );
8346            }
8347        }
8348        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
8349            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
8350            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
8351            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8352        self.failed_commits.push((
8353            OutboxCommit {
8354                client_commit_id: id.to_owned(),
8355                ops: operations.to_vec(),
8356            },
8357            initial,
8358        ));
8359        Ok(())
8360    }
8361
8362    fn drop_failed_commits(
8363        &mut self,
8364        matches: impl Fn(&str, &Map<String, Value>) -> bool,
8365    ) -> Result<Vec<OutboxCommit>, String> {
8366        let mut doomed = Vec::new();
8367        for (commit, initial) in &self.failed_commits {
8368            for operation in &commit.ops {
8369                let key =
8370                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8371                        .map_err(|_| "sync.local_corrupt".to_owned())?;
8372                if initial
8373                    .get(&key)
8374                    .and_then(Value::as_object)
8375                    .is_some_and(|values| matches(&operation.table, values))
8376                {
8377                    doomed.push(commit.clone());
8378                    break;
8379                }
8380            }
8381        }
8382        for commit in &doomed {
8383            self.conn
8384                .execute(
8385                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
8386                    rusqlite::params![commit.client_commit_id],
8387                )
8388                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8389        }
8390        self.failed_commits.retain(|(commit, _)| {
8391            !doomed
8392                .iter()
8393                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
8394        });
8395        if !doomed.is_empty() {
8396            self.overlay_dirty.set(true);
8397        }
8398        Ok(doomed)
8399    }
8400
8401    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
8402        if mutations.is_empty() {
8403            return Err("a commit must contain at least one operation (§6.1)".to_owned());
8404        }
8405        let mut ops = Vec::with_capacity(mutations.len());
8406        for mutation in mutations {
8407            match mutation {
8408                Mutation::Patch {
8409                    table,
8410                    values,
8411                    base_version,
8412                } => {
8413                    let schema_table = self
8414                        .schema
8415                        .table(&table)
8416                        .ok_or_else(|| "sync.unknown_table".to_owned())?;
8417                    let values = normalize_values_casing(schema_table, values)?;
8418                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8419                    ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
8420                }
8421                Mutation::Upsert {
8422                    table,
8423                    values,
8424                    base_version,
8425                } => {
8426                    let schema_table = self
8427                        .schema
8428                        .table(&table)
8429                        .ok_or_else(|| format!("unknown table {table:?}"))?;
8430                    // §5: value keys are accepted in snake_case AND the
8431                    // generated row types' camelCase; normalize to SQL truth
8432                    // before the pk lookup / codec see them.
8433                    let values = normalize_values_casing(schema_table, values)?;
8434                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8435                    // §6.7 `mutate` marks every column present: fill missing
8436                    // nullable columns with NULL before the payload is built.
8437                    let values = full_row_values(schema_table, values)?;
8438                    // §5.11: validate the payload encodes with the current
8439                    // codec. A key-selection or unknown-key failure is a
8440                    // durable rejection at the push seam (§10.3), never an
8441                    // author-time error. Full-row `mutate` presents every
8442                    // column, so no stored fallback is needed.
8443                    self.validate_author_encode(schema_table, &row_id, &values)?;
8444                    ops.push(OutboxOp {
8445                        upsert: true,
8446                        table,
8447                        row_id,
8448                        base_version,
8449                        values: Some(values),
8450                    });
8451                }
8452                Mutation::Delete {
8453                    table,
8454                    row_id,
8455                    base_version,
8456                } => {
8457                    if self.schema.table(&table).is_none() {
8458                        return Err(format!("unknown table {table:?}"));
8459                    }
8460                    ops.push(OutboxOp {
8461                        upsert: false,
8462                        table,
8463                        row_id,
8464                        base_version,
8465                        values: None,
8466                    });
8467                }
8468            }
8469        }
8470        self.record_outbox_commit(ops)
8471    }
8472
8473    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
8474        let commit = OutboxCommit {
8475            client_commit_id: uuid::Uuid::new_v4().to_string(),
8476            ops,
8477        };
8478        self.begin_observation("syncular_mutation")?;
8479        let mut batch = ChangeAccumulator::default();
8480        for op in &commit.ops {
8481            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
8482            if let Some(values) = &op.values {
8483                if let Some(table) = self.schema.table(&op.table) {
8484                    for scope in &table.scope_variables {
8485                        if let Some(Value::String(value)) = values.get(&scope.column) {
8486                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
8487                            precise = true;
8488                        }
8489                    }
8490                }
8491            }
8492            if !precise {
8493                batch.table(&op.table);
8494            }
8495        }
8496        if let Err(error) = self.persist_outbox_insert(&commit) {
8497            self.rollback_observation("syncular_mutation");
8498            return Err(error);
8499        }
8500        let was_dirty = self.overlay_dirty.get();
8501        // A clean overlay contains the FIFO fold of every previous commit, so
8502        // the new commit applies over it. §7.1: an operation that does not
8503        // apply (a secondary unique collision) fails the whole commit; the
8504        // savepoint rollback removes the outbox entry and every visible write.
8505        if was_dirty {
8506            self.rebuild_overlay();
8507        }
8508        if let Some(error) = commit
8509            .ops
8510            .iter()
8511            .find_map(|op| self.apply_outbox_op(op).err())
8512        {
8513            self.rollback_observation("syncular_mutation");
8514            self.overlay_dirty.set(was_dirty);
8515            return Err(error);
8516        }
8517        let id = commit.client_commit_id.clone();
8518        self.outbox.push(commit);
8519        batch.status = true;
8520        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
8521            self.rollback_observation("syncular_mutation");
8522            self.outbox.pop();
8523            self.overlay_dirty.set(was_dirty);
8524            return Err(error);
8525        }
8526        Ok(id)
8527    }
8528
8529    /// Record one §6.1 sparse upsert: the primary key plus the supplied
8530    /// non-scope columns are present; every other column is absent and stays
8531    /// untouched on the server and in the local overlay. The row need not be
8532    /// locally present — an absent local row leaves the overlay absent (§7.1)
8533    /// and the server answers per §5.2.
8534    pub fn patch(
8535        &mut self,
8536        table: &str,
8537        row_id: &str,
8538        partial: Map<String, Value>,
8539        base_version: Option<i64>,
8540    ) -> Result<String, String> {
8541        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
8542        self.record_outbox_commit(vec![operation])
8543    }
8544
8545    fn prepare_patch(
8546        &self,
8547        table: &str,
8548        row_id: &str,
8549        partial: Map<String, Value>,
8550        base_version: Option<i64>,
8551    ) -> Result<OutboxOp, String> {
8552        let schema_table = self
8553            .schema
8554            .table(table)
8555            .ok_or_else(|| format!("unknown table {table:?}"))?;
8556        let partial = normalize_values_casing(schema_table, partial)?;
8557        if let Some(pk) = partial.get(&schema_table.primary_key) {
8558            if pk.as_str() != Some(row_id) {
8559                return Err(format!(
8560                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
8561                ));
8562            }
8563        }
8564        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
8565        // server accepts a present scope column whose value equals the
8566        // stored row and applies it as a no-op, so a decoded envelope
8567        // round-trips. The local row is the only value the client can prove
8568        // equality against; when the row is absent locally the client cannot
8569        // prove equality against the server's stored row and fails closed.
8570        // A primary key that is also a scope column needs no stored-row
8571        // read: the primary key in a sparse payload is by construction the
8572        // row id being patched (§6.1), so its value is proven equal already.
8573        for scope in &schema_table.scope_variables {
8574            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
8575                continue;
8576            }
8577            let Some(column) = schema_table
8578                .columns
8579                .iter()
8580                .find(|column| column.name == scope.column)
8581            else {
8582                continue;
8583            };
8584            let stored = self
8585                .conn
8586                .query_row(
8587                    &format!(
8588                        "SELECT {} FROM {} WHERE {}",
8589                        quote_ident(&scope.column),
8590                        visible_table(&schema_table.name),
8591                        row_id_predicate(schema_table)
8592                    ),
8593                    rusqlite::params![row_id],
8594                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
8595                )
8596                .ok();
8597            // Decode the supplied value through the same column-type path the
8598            // rest of the patch encoding uses, so a value that only equals
8599            // the stored one in another number representation still compares
8600            // equal.
8601            let incoming = json_to_column_value(column, partial.get(&scope.column))
8602                .ok()
8603                .map(|value| column_value_to_json(&value));
8604            // A stored blob never equals a patch value, matching the server's
8605            // scope-column comparison (§3.4): bytes are unstorable as scope
8606            // values.
8607            let equal = stored.as_ref().is_some_and(|stored| {
8608                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
8609            });
8610            if !equal {
8611                return Err(format!(
8612                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
8613                    scope.column
8614                ));
8615            }
8616        }
8617        let mut values = partial;
8618        // Presence-set semantics: a proven-equal scope column is dropped, so
8619        // the patch leaves the stored value untouched (§6.2).
8620        for scope in &schema_table.scope_variables {
8621            values.remove(&scope.column);
8622        }
8623        values.insert(
8624            schema_table.primary_key.clone(),
8625            Value::from(row_id.to_owned()),
8626        );
8627        // §5.11: structural validation only; an absent key-id selector is
8628        // resolved at the push seam from the stored local row, and a
8629        // key-selection or unknown-key failure becomes a durable
8630        // `client.encrypt_failed` rejection there (§10.3), never an
8631        // author-time error.
8632        self.validate_author_encode(schema_table, row_id, &values)?;
8633        Ok(OutboxOp {
8634            upsert: true,
8635            table: table.to_owned(),
8636            row_id: row_id.to_owned(),
8637            base_version,
8638            values: Some(values),
8639        })
8640    }
8641
8642    /// §5.11 key-id fallback: the stored local row's selector value as a
8643    /// positional row (only the selector slot filled). Returns None when no
8644    /// selector is configured, the row is locally absent, or the stored
8645    /// value is not a usable key id.
8646    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
8647        let selector = self.encryption.key_id_columns.get(&table.name)?;
8648        let index = table
8649            .columns
8650            .iter()
8651            .position(|column| &column.name == selector)?;
8652        let sql = format!(
8653            "SELECT {} FROM {} WHERE {}",
8654            quote_ident(selector),
8655            visible_table(&table.name),
8656            row_id_predicate(table)
8657        );
8658        let key: Option<String> = self
8659            .conn
8660            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
8661            .ok()?;
8662        let key = key.filter(|key| !key.is_empty())?;
8663        let mut fallback: Row = vec![None; table.columns.len()];
8664        fallback[index] = Some(ColumnValue::String(key));
8665        Some(fallback)
8666    }
8667
8668    /// §5.11/§10.3 author-time encode check: a structural failure (a
8669    /// non-nullable NULL, a value the codec rejects) is an author-time
8670    /// error; a key-selection or unknown-key failure is deferred to the push
8671    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
8672    fn validate_author_encode(
8673        &self,
8674        table: &TableSchema,
8675        row_id: &str,
8676        values: &Map<String, Value>,
8677    ) -> Result<(), String> {
8678        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
8679            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
8680            result => result.map(|_| ()),
8681        }
8682    }
8683
8684    /// §5.11/§10.3: drop every pending commit whose push encode fails with
8685    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
8686    /// raise one durable rejection per commit. `sync()` never aborts for an
8687    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
8688    /// undoing the dropped commit's purely-optimistic rows.
8689    fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
8690        if !self
8691            .schema
8692            .tables
8693            .iter()
8694            .any(|table| table.has_encrypted_columns())
8695        {
8696            return Ok(false);
8697        }
8698        let failures = self
8699            .outbox
8700            .iter()
8701            .filter_map(|commit| {
8702                commit
8703                    .ops
8704                    .iter()
8705                    .find_map(|op| {
8706                        let values = op.values.as_ref().filter(|_| op.upsert)?;
8707                        let table = self.schema.table(&op.table)?;
8708                        let fallback = self.stored_key_fallback(table, &op.row_id);
8709                        match encode_sparse_row_json(
8710                            table,
8711                            &op.row_id,
8712                            values,
8713                            &self.encryption,
8714                            fallback.as_ref(),
8715                        ) {
8716                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
8717                            _ => None,
8718                        }
8719                    })
8720                    .map(|error| (commit.clone(), error))
8721            })
8722            .collect::<Vec<_>>();
8723        if failures.is_empty() {
8724            return Ok(false);
8725        }
8726        for (commit, message) in failures {
8727            if self.retain_failed_commits {
8728                self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
8729            }
8730            let rejection = RejectionRecord {
8731                client_commit_id: commit.client_commit_id.clone(),
8732                op_index: 0,
8733                code: ENCRYPT_FAILED_CODE.to_owned(),
8734                message,
8735                retryable: false,
8736                details: None,
8737                operation: commit.ops.first().map(CommitOperation::from),
8738            };
8739            self.persist_commit_outcome(
8740                &commit.client_commit_id,
8741                CommitOutcomeStatus::Rejected,
8742                &[CommitOperationOutcome::Error {
8743                    rejection: rejection.clone(),
8744                }],
8745                Some(&commit.ops),
8746            )?;
8747            self.delete_outbox_persisted(&commit.client_commit_id)?;
8748            self.outbox
8749                .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
8750            self.rejections.push(rejection);
8751        }
8752        self.prune_commit_outcomes()?;
8753        self.overlay_dirty.set(true);
8754        self.rebuild_overlay();
8755        Ok(true)
8756    }
8757
8758    pub fn pending_commit_ids(&self) -> Vec<String> {
8759        self.outbox
8760            .iter()
8761            .map(|c| c.client_commit_id.clone())
8762            .collect()
8763    }
8764
8765    /// §6.1: the sparse push payload bytes of every pending upsert operation,
8766    /// FIFO — exactly what the next push round would put on the wire. Used by
8767    /// conformance B.21(h) to pin cross-core byte identity.
8768    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
8769        let mut payloads = Vec::new();
8770        for commit in &self.outbox {
8771            for op in &commit.ops {
8772                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
8773                    continue;
8774                };
8775                let Some(table) = self.schema.table(&op.table) else {
8776                    continue;
8777                };
8778                let fallback = self.stored_key_fallback(table, &op.row_id);
8779                if let Ok(payload) = encode_sparse_row_json(
8780                    table,
8781                    &op.row_id,
8782                    values,
8783                    &self.encryption,
8784                    fallback.as_ref(),
8785                ) {
8786                    payloads.push(payload);
8787                }
8788            }
8789        }
8790        payloads
8791    }
8792
8793    pub fn conflicts(&self) -> &[ConflictRecord] {
8794        &self.conflicts
8795    }
8796
8797    pub fn rejections(&self) -> &[RejectionRecord] {
8798        &self.rejections
8799    }
8800
8801    pub fn resolve_commit_outcome(
8802        &mut self,
8803        input: ResolveCommitOutcomeInput,
8804    ) -> Result<CommitOutcome, String> {
8805        let current = self
8806            .commit_outcome(&input.client_commit_id)?
8807            .ok_or_else(|| {
8808                format!(
8809                    "sync.outcome_not_found: no durable outcome exists for {:?}",
8810                    input.client_commit_id
8811                )
8812            })?;
8813        if current.resolution != CommitOutcomeResolution::Active {
8814            return Ok(current);
8815        }
8816        if input.resolution == CommitOutcomeResolution::Active {
8817            return Err("sync.invalid_request: resolution must leave active state".to_owned());
8818        }
8819        match input.resolution {
8820            CommitOutcomeResolution::Superseded => {
8821                let replacement = input
8822                    .replacement_client_commit_id
8823                    .as_deref()
8824                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8825                    .ok_or_else(|| {
8826                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8827                            .to_owned()
8828                    })?;
8829                let _ = replacement;
8830            }
8831            _ if input.replacement_client_commit_id.is_some() => {
8832                return Err(
8833                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8834                        .to_owned(),
8835                );
8836            }
8837            _ => {}
8838        }
8839        let allowed = match current.status {
8840            CommitOutcomeStatus::Conflict => matches!(
8841                input.resolution,
8842                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8843            ),
8844            CommitOutcomeStatus::Rejected => {
8845                matches!(
8846                    input.resolution,
8847                    CommitOutcomeResolution::Superseded
8848                        | CommitOutcomeResolution::ResolvedKeepServer
8849                )
8850            }
8851            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8852                input.resolution == CommitOutcomeResolution::Dismissed
8853            }
8854        };
8855        if !allowed {
8856            return Err(format!(
8857                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8858                input.resolution, current.status
8859            ));
8860        }
8861
8862        self.begin_observation("syncular_outcome_resolution")?;
8863        let previous_failed = self.failed_commits.clone();
8864        let previous_conflicts = self.conflicts.clone();
8865        let previous_rejections = self.rejections.clone();
8866        let result = (|| {
8867            self.conn
8868                .execute(
8869                    "UPDATE _syncular_commit_outcomes
8870                        SET resolution = ?1, resolved_at_ms = ?2,
8871                            replacement_client_commit_id = ?3
8872                      WHERE client_commit_id = ?4 AND resolution = 'active'",
8873                    rusqlite::params![
8874                        Self::outcome_resolution_name(input.resolution),
8875                        self.clock_now_ms(),
8876                        input.replacement_client_commit_id,
8877                        input.client_commit_id
8878                    ],
8879                )
8880                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8881            let resolved = self
8882                .commit_outcome(&current.client_commit_id)?
8883                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8884            self.conn
8885                .execute(
8886                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
8887                    rusqlite::params![input.client_commit_id],
8888                )
8889                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8890            self.failed_commits
8891                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
8892            self.overlay_dirty.set(true);
8893            self.prune_commit_outcomes()?;
8894            Ok(resolved)
8895        })();
8896        let resolved = match result {
8897            Ok(outcome) => outcome,
8898            Err(error) => {
8899                self.rollback_observation("syncular_outcome_resolution");
8900                self.failed_commits = previous_failed;
8901                self.conflicts = previous_conflicts;
8902                self.rejections = previous_rejections;
8903                return Err(error);
8904            }
8905        };
8906        self.conflicts
8907            .retain(|record| record.client_commit_id != current.client_commit_id);
8908        self.rejections
8909            .retain(|record| record.client_commit_id != current.client_commit_id);
8910        let mut batch = ChangeAccumulator {
8911            conflicts: current.status == CommitOutcomeStatus::Conflict,
8912            rejections: current.status == CommitOutcomeStatus::Rejected,
8913            outcomes: true,
8914            ..ChangeAccumulator::default()
8915        };
8916        if let Some(operations) = &current.operations {
8917            for operation in operations {
8918                batch.table(&operation.table);
8919            }
8920        }
8921        self.rebuild_overlay_if_dirty();
8922        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8923            self.rollback_observation("syncular_outcome_resolution");
8924            self.failed_commits = previous_failed;
8925            self.conflicts = previous_conflicts;
8926            self.rejections = previous_rejections;
8927            return Err(error);
8928        }
8929        Ok(resolved)
8930    }
8931
8932    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8933        self.schema_floor.as_ref()
8934    }
8935
8936    /// §7.3.5: the client's opaque auth-lease state, if any.
8937    pub fn lease_state(&self) -> Option<&LeaseState> {
8938        self.lease_state.as_ref()
8939    }
8940
8941    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
8942    /// the two lease codes set it; other errors leave leaseState untouched.
8943    fn record_lease_error(&mut self, code: &str) {
8944        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8945            return;
8946        }
8947        let mut next = self.lease_state.clone().unwrap_or_default();
8948        next.error_code = Some(code.to_owned());
8949        self.set_lease_state(Some(next));
8950    }
8951
8952    fn set_lease_state(&mut self, next: Option<LeaseState>) {
8953        if self.lease_state == next {
8954            return;
8955        }
8956        if self.begin_observation("syncular_lease").is_err() {
8957            return;
8958        }
8959        self.lease_state = next;
8960        if let Some(lease) = &self.lease_state {
8961            if let Ok(json) = serde_json::to_string(lease) {
8962                self.set_meta(LEASE_STATE_KEY, &json);
8963            }
8964        } else {
8965            self.delete_meta(LEASE_STATE_KEY);
8966        }
8967        let batch = ChangeAccumulator {
8968            status: true,
8969            ..ChangeAccumulator::default()
8970        };
8971        if self.finish_observation("syncular_lease", batch).is_err() {
8972            self.rollback_observation("syncular_lease");
8973        }
8974    }
8975
8976    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
8977        if self.schema_floor == next {
8978            return;
8979        }
8980        if self.begin_observation("syncular_schema_floor").is_err() {
8981            return;
8982        }
8983        self.schema_floor = next;
8984        self.stopped = self.schema_floor.is_some();
8985        if let Some(floor) = &self.schema_floor {
8986            if let Ok(json) = serde_json::to_string(floor) {
8987                self.set_meta(SCHEMA_FLOOR_KEY, &json);
8988            }
8989        } else {
8990            self.delete_meta(SCHEMA_FLOOR_KEY);
8991        }
8992        let batch = ChangeAccumulator {
8993            status: true,
8994            ..ChangeAccumulator::default()
8995        };
8996        if self
8997            .finish_observation("syncular_schema_floor", batch)
8998            .is_err()
8999        {
9000            self.rollback_observation("syncular_schema_floor");
9001        }
9002    }
9003
9004    fn set_upgrading(&mut self, value: bool) {
9005        if self.upgrading == value {
9006            return;
9007        }
9008        if self.begin_observation("syncular_upgrading").is_err() {
9009            return;
9010        }
9011        self.upgrading = value;
9012        let batch = ChangeAccumulator {
9013            status: true,
9014            ..ChangeAccumulator::default()
9015        };
9016        if self
9017            .finish_observation("syncular_upgrading", batch)
9018            .is_err()
9019        {
9020            self.rollback_observation("syncular_upgrading");
9021        }
9022    }
9023
9024    pub fn sync_needed(&self) -> bool {
9025        self.sync_needed
9026    }
9027
9028    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
9029        let sub = self.subs.iter().find(|s| s.id == id)?;
9030        Some(SubscriptionStateView {
9031            id: sub.id.clone(),
9032            table: sub.table.clone(),
9033            status: sub.state.name().to_owned(),
9034            cursor: sub.cursor,
9035            has_resume_token: sub.bootstrap_state.is_some(),
9036            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
9037            reason_code: sub.reason_code.clone(),
9038        })
9039    }
9040
9041    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
9042        let schema_table = self
9043            .schema
9044            .table(table)
9045            .ok_or_else(|| format!("unknown table {table:?}"))?;
9046        let sql = format!(
9047            "SELECT * FROM {} ORDER BY {} ASC",
9048            visible_table(table),
9049            quote_ident(&schema_table.primary_key)
9050        );
9051        let mut stmt = self
9052            .conn
9053            .prepare(&sql)
9054            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9055        let mut rows = stmt
9056            .query([])
9057            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9058        let mut out = Vec::new();
9059        while let Some(row) = rows
9060            .next()
9061            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9062        {
9063            let mut values = Map::new();
9064            for (i, column) in schema_table.columns.iter().enumerate() {
9065                let value = row
9066                    .get_ref(i)
9067                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9068                values.insert(column.name.clone(), sql_ref_to_json(column, value));
9069            }
9070            let version: i64 = row
9071                .get(schema_table.columns.len())
9072                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9073            let row_id = match values.get(&schema_table.primary_key) {
9074                Some(Value::String(s)) => s.clone(),
9075                Some(Value::Number(n)) => n.to_string(),
9076                Some(Value::Bool(b)) => b.to_string(),
9077                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
9078            };
9079            out.push(RowState {
9080                row_id,
9081                version,
9082                values,
9083            });
9084        }
9085        Ok(out)
9086    }
9087
9088    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
9089    //
9090    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
9091    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
9092    // the op with `yrs`, re-encodes the whole doc state, and records a
9093    // baseVersion-less sparse upsert carrying only the primary key and the
9094    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
9095    // the overlay re-materializes the edit immediately (optimistic apply,
9096    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
9097    // Byte-compatible with `@syncular/crdt-yjs`.
9098
9099    /// The current stored value of a `crdt` column for one row — the visible
9100    /// (optimistic) bytes, or `None` when the row is absent or the column is
9101    /// NULL (the empty document, §5.10.1). Errors if the column is not a
9102    /// `crdt` column (guards the app against a typo'd column name).
9103    #[cfg(feature = "crdt-yjs")]
9104    fn crdt_column_bytes(
9105        &self,
9106        table: &str,
9107        row_id: &str,
9108        column: &str,
9109    ) -> Result<Option<Vec<u8>>, String> {
9110        let schema_table = self
9111            .schema
9112            .table(table)
9113            .ok_or_else(|| format!("unknown table {table:?}"))?;
9114        let col = schema_table
9115            .columns
9116            .iter()
9117            .find(|c| c.name == column)
9118            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
9119        if col.ty != ColumnType::Crdt {
9120            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
9121        }
9122        let sql = format!(
9123            "SELECT {} FROM {} WHERE {}",
9124            quote_ident(column),
9125            visible_table(table),
9126            row_id_predicate(schema_table)
9127        );
9128        let bytes: Option<Vec<u8>> = self
9129            .conn
9130            .prepare_cached(&sql)
9131            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9132            .query_row(rusqlite::params![row_id], |row| {
9133                row.get::<_, Option<Vec<u8>>>(0)
9134            })
9135            .map_err(|e| match e {
9136                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
9137                other => other.to_string(),
9138            })?;
9139        Ok(bytes)
9140    }
9141
9142    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
9143    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
9144    /// An absent row / NULL column is the empty document (empty string).
9145    #[cfg(feature = "crdt-yjs")]
9146    pub fn crdt_text(
9147        &self,
9148        table: &str,
9149        row_id: &str,
9150        column: &str,
9151        name: &str,
9152    ) -> Result<String, String> {
9153        let bytes = self
9154            .crdt_column_bytes(table, row_id, column)?
9155            .unwrap_or_default();
9156        crate::crdt::text(&bytes, name)
9157    }
9158
9159    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
9160    /// record the resulting crdt-only sparse operation. Returns the enqueued
9161    /// `clientCommitId`.
9162    #[cfg(feature = "crdt-yjs")]
9163    pub fn crdt_insert_text(
9164        &mut self,
9165        table: &str,
9166        row_id: &str,
9167        column: &str,
9168        name: &str,
9169        index: u32,
9170        value: &str,
9171    ) -> Result<String, String> {
9172        let current = self
9173            .crdt_column_bytes(table, row_id, column)?
9174            .unwrap_or_default();
9175        let update = crate::crdt::insert_text(&current, name, index, value)?;
9176        self.crdt_push_update(table, row_id, column, &update)
9177    }
9178
9179    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
9180    /// record the resulting crdt-only sparse operation. Returns the enqueued
9181    /// `clientCommitId`.
9182    #[cfg(feature = "crdt-yjs")]
9183    pub fn crdt_delete_text(
9184        &mut self,
9185        table: &str,
9186        row_id: &str,
9187        column: &str,
9188        name: &str,
9189        index: u32,
9190        len: u32,
9191    ) -> Result<String, String> {
9192        let current = self
9193            .crdt_column_bytes(table, row_id, column)?
9194            .unwrap_or_default();
9195        let update = crate::crdt::delete_text(&current, name, index, len)?;
9196        self.crdt_push_update(table, row_id, column, &update)
9197    }
9198
9199    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
9200    /// `crdt` column's current state and record the resulting crdt-only sparse
9201    /// operation. The app authored the update with its own `yrs` model.
9202    /// Returns the enqueued `clientCommitId`.
9203    #[cfg(feature = "crdt-yjs")]
9204    pub fn crdt_apply_update(
9205        &mut self,
9206        table: &str,
9207        row_id: &str,
9208        column: &str,
9209        update: &[u8],
9210    ) -> Result<String, String> {
9211        let current = self
9212            .crdt_column_bytes(table, row_id, column)?
9213            .unwrap_or_default();
9214        let next = crate::crdt::apply_update(&current, update)?;
9215        self.crdt_push_update(table, row_id, column, &next)
9216    }
9217
9218    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
9219    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
9220    /// enqueue it. The server merges the crdt column and leaves every other
9221    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
9222    /// clobber a concurrent edit to another column. A locally absent row
9223    /// records the same partial operation; the server answers per §6.2.
9224    #[cfg(feature = "crdt-yjs")]
9225    fn crdt_push_update(
9226        &mut self,
9227        table: &str,
9228        row_id: &str,
9229        column: &str,
9230        crdt_bytes: &[u8],
9231    ) -> Result<String, String> {
9232        let mut bytes_obj = Map::new();
9233        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
9234        let mut partial = Map::new();
9235        partial.insert(column.to_owned(), Value::Object(bytes_obj));
9236        self.patch(table, row_id, partial, None)
9237    }
9238
9239    /// Run an arbitrary read-only SQL query against the local database and
9240    /// return each row as a `column-name → JSON value` map. This is the seam
9241    /// the React `useSyncQuery` live-query API needs (it takes app-authored
9242    /// SQL over the visible tables/views, not a fixed table read like
9243    /// [`read_rows`]).
9244    ///
9245    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
9246    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
9247    /// same envelope the command surface uses everywhere else. Output BLOB
9248    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
9249    ///
9250    /// The result column typing is dynamic (SQLite's stored affinity), because
9251    /// arbitrary SQL can alias, join, and compute — there is no schema column
9252    /// to consult per output cell, unlike [`read_rows`].
9253    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
9254        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
9255    }
9256
9257    /// Repository benchmark and conformance access, present only with `bench-internals`.
9258    #[cfg(feature = "bench-internals")]
9259    #[doc(hidden)]
9260    pub fn benchmark_connection(&mut self) -> &mut Connection {
9261        &mut self.conn
9262    }
9263
9264    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
9265    #[cfg(feature = "bench-internals")]
9266    #[doc(hidden)]
9267    pub fn benchmark_phases(
9268        &self,
9269        enabled: Option<bool>,
9270        reset: bool,
9271    ) -> Result<Option<Value>, String> {
9272        self.benchmark_phases.configure(enabled, reset)?;
9273        Ok(self.benchmark_phases.snapshot())
9274    }
9275
9276    /// Rows, coverage, and local revision from one SQLite read snapshot.
9277    /// §7.5 atomic snapshot read. An `owner` records a failure in
9278    /// `diagnostics_snapshot().query_failures` until its next successful read.
9279    pub fn query_snapshot(
9280        &mut self,
9281        sql: &str,
9282        params: &[QueryValue],
9283        coverage: &[WindowCoverage],
9284        owner: Option<QueryOwner<'_>>,
9285    ) -> Result<QuerySnapshot, String> {
9286        let result = snapshot_connection(&self.conn, sql, params, coverage);
9287        if let Some(owner) = owner {
9288            self.record_query_read(owner, result.as_ref().err());
9289        }
9290        result.map_err(|failure| failure.to_string())
9291    }
9292
9293    /// §7.6: record the outcome of one owned snapshot read, including reads a
9294    /// host ran on a separate connection. A success removes the owner's entry;
9295    /// an identical failure leaves it unchanged; a changed failure moves last.
9296    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
9297        let position = self
9298            .query_failures
9299            .iter()
9300            .position(|entry| entry.id == owner.id);
9301        let Some(failure) = failure else {
9302            if let Some(index) = position {
9303                self.query_failures.remove(index);
9304            }
9305            return;
9306        };
9307        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
9308            .into_iter()
9309            .map(str::to_owned)
9310            .collect();
9311        let code = failure.code.unwrap_or("client.query_failed").to_owned();
9312        if position.is_some_and(|index| {
9313            let entry = &self.query_failures[index];
9314            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
9315        }) {
9316            return;
9317        }
9318        if let Some(index) = position {
9319            self.query_failures.remove(index);
9320        }
9321        self.query_failures.push(DiagnosticQueryFailure {
9322            id: owner.id.to_owned(),
9323            tables,
9324            code,
9325            sqlite_code: failure.sqlite_code,
9326            at_ms: self.clock_now_ms(),
9327        });
9328        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
9329            self.query_failures.remove(0);
9330        }
9331    }
9332
9333    // -- request building ---------------------------------------------------------
9334
9335    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
9336        #[cfg(feature = "bench-internals")]
9337        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
9338        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
9339        let mut frames = vec![Frame::ReqHeader {
9340            client_id: self.client_id.clone(),
9341            schema_version: self.schema.version,
9342            log_epoch: log_epoch.clone(),
9343        }];
9344        let mut pushed_ids = Vec::new();
9345        let mut ops_in_request = 0usize;
9346        let mut deferred_commits = 0usize;
9347        #[cfg(feature = "bench-internals")]
9348        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
9349        for (index, commit) in self
9350            .outbox
9351            .iter()
9352            .take(if log_epoch.is_some() {
9353                self.outbox.len()
9354            } else {
9355                0
9356            })
9357            .enumerate()
9358        {
9359            // §6.1 splitBatch: stop at the operation cap — commits apply in
9360            // order, so everything from the first non-fitting commit on is
9361            // deferred to the next round. A single over-cap commit still goes
9362            // alone (commits are atomic and cannot be split).
9363            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
9364                deferred_commits = self.outbox.len() - index;
9365                break;
9366            }
9367            ops_in_request += commit.ops.len();
9368            let operations = commit
9369                .ops
9370                .iter()
9371                .map(|op| {
9372                    let payload = op.values.as_ref().and_then(|values| {
9373                        let table = self.schema.table(&op.table)?;
9374                        // §0: outbox entries encode at send time with the
9375                        // current codec (validated at mutate()). §6.1: the
9376                        // values map's keys are the sparse presence set.
9377                        // §5.11: encrypted columns are encrypted here, with
9378                        // an absent key-id selector read from the stored row.
9379                        // `sync_inner` runs `drop_unencodable_outbox` first,
9380                        // so an encrypt failure cannot reach this encode; the
9381                        // author seam validated every other failure.
9382                        let fallback = self.stored_key_fallback(table, &op.row_id);
9383                        encode_sparse_row_json(
9384                            table,
9385                            &op.row_id,
9386                            values,
9387                            &self.encryption,
9388                            fallback.as_ref(),
9389                        )
9390                        .ok()
9391                    });
9392                    ssp2::model::Operation {
9393                        table: op.table.clone(),
9394                        row_id: op.row_id.clone(),
9395                        op: if op.upsert { Op::Upsert } else { Op::Delete },
9396                        base_version: op.base_version,
9397                        payload,
9398                    }
9399                })
9400                .collect();
9401            frames.push(Frame::PushCommit {
9402                client_commit_id: commit.client_commit_id.clone(),
9403                operations,
9404            });
9405            pushed_ids.push(commit.client_commit_id.clone());
9406        }
9407        #[cfg(feature = "bench-internals")]
9408        drop(outbox_phase);
9409        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
9410        // bare URL — capability negotiation, decided per transport.
9411        let accept = self.limits.accept.unwrap_or(if url_capable {
9412            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
9413        } else {
9414            DEFAULT_ACCEPT
9415        });
9416        frames.push(Frame::PullHeader {
9417            limit_commits: self.limits.limit_commits.unwrap_or(0),
9418            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
9419            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
9420            accept,
9421        });
9422        let mut fresh = Vec::new();
9423        for sub in &self.subs {
9424            if sub.state != SubState::Active {
9425                continue;
9426            }
9427            let mut scopes = sub.requested.clone();
9428            sort_scope_map(&mut scopes);
9429            frames.push(Frame::Subscription {
9430                id: sub.id.clone(),
9431                table: sub.table.clone(),
9432                scopes,
9433                params: sub.params.clone().map(RawJson),
9434                cursor: sub.cursor,
9435                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
9436            });
9437            fresh.push((
9438                sub.id.clone(),
9439                sub.cursor < 0 && sub.bootstrap_state.is_none(),
9440            ));
9441        }
9442        let message = Message {
9443            wire_version: WIRE_VERSION,
9444            msg_kind: MsgKind::Request,
9445            frames,
9446        };
9447        (
9448            message,
9449            RequestMeta {
9450                pushed_ids,
9451                fresh,
9452                accept,
9453                deferred_commits,
9454            },
9455        )
9456    }
9457
9458    // -- sync -------------------------------------------------------------------
9459
9460    /// Cloneable observation handle; no client lock is needed to read it.
9461    pub fn progress(&self) -> ProgressObserver {
9462        self.progress.clone()
9463    }
9464
9465    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9466        self.storage_failure.borrow_mut().take();
9467        self.progress.start();
9468        self.round_retry_delay_ms = None;
9469        let started_at_ms = self.clock_now_ms();
9470        let mut outcome = self.sync_inner(transport);
9471        if let SyncOutcome::Failed {
9472            error_code,
9473            message,
9474            details,
9475        } = &mut outcome
9476        {
9477            if let Some(failure) = self.storage_failure.borrow().as_ref() {
9478                let code = failure
9479                    .code
9480                    .expect("retained storage failures are classified");
9481                *error_code = code.to_owned();
9482                *message = match code {
9483                    "client.storage_full" => "local SQLite storage is full",
9484                    "client.storage_io" => "local SQLite storage I/O failed",
9485                    "client.storage_corrupt" => "local SQLite storage is corrupt",
9486                    _ => unreachable!("unrecognized classified storage failure"),
9487                }
9488                .to_owned();
9489                *details = failure.details();
9490            }
9491        }
9492        let retry_delay_ms = self.round_retry_delay_ms;
9493        self.progress.update(|p| match &outcome {
9494            SyncOutcome::Ok(report) if report.failed.is_empty() => {
9495                p.state = ProgressState::Complete
9496            }
9497            SyncOutcome::Ok(_) => {
9498                p.state = ProgressState::Failed;
9499                p.error_code = Some("sync.scope_revoked".into());
9500            }
9501            SyncOutcome::Failed { error_code, .. } => {
9502                p.state = ProgressState::Failed;
9503                p.error_code = Some(Self::diagnostic_code(error_code));
9504                p.retry_delay_ms = retry_delay_ms;
9505            }
9506            SyncOutcome::RealtimeUnavailable { .. } => {
9507                p.state = ProgressState::Failed;
9508                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
9509                p.retry_delay_ms = retry_delay_ms;
9510            }
9511        });
9512        let completed_at_ms = self.clock_now_ms();
9513        self.last_round = Some(match &outcome {
9514            SyncOutcome::Ok(report) => DiagnosticLastRound {
9515                status: "succeeded".to_owned(),
9516                started_at_ms,
9517                completed_at_ms,
9518                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9519                counters: Some(DiagnosticRoundCounters {
9520                    pushed: report.pushed,
9521                    applied: report.applied.len(),
9522                    rejected: report.rejected.len(),
9523                    retryable: report.retryable.len(),
9524                    conflicts: report.conflicts,
9525                    commits_applied: report.commits_applied,
9526                    segment_rows_applied: report.segment_rows_applied,
9527                    bootstrapping: report.bootstrapping.len(),
9528                    resets: report.resets.len(),
9529                    revoked: report.revoked.len(),
9530                    failed: report.failed.len(),
9531                    deferred_commits: report.deferred_commits,
9532                }),
9533                error_code: None,
9534            },
9535            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
9536                status: "failed".to_owned(),
9537                started_at_ms,
9538                completed_at_ms,
9539                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9540                counters: None,
9541                error_code: Some(Self::diagnostic_code(error_code)),
9542            },
9543            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
9544                status: "failed".to_owned(),
9545                started_at_ms,
9546                completed_at_ms,
9547                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9548                counters: None,
9549                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
9550            },
9551        });
9552        outcome
9553    }
9554
9555    fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9556        if self.stopped {
9557            // §1.6: the client stopped at the schema floor; syncing is inert
9558            // until an upgrade. The outbox is preserved for replay.
9559            return SyncOutcome::Ok(SyncReport {
9560                schema_floor: self.schema_floor.clone(),
9561                ..SyncReport::default()
9562            });
9563        }
9564        // §8.4: the coalesced sync-needed signal clears when a pull round
9565        // BEGINS, so a wake-up landing mid-round survives it.
9566        self.set_sync_needed(false, false);
9567        // §5.9.7 B4: upload pending blobs before pushing the referencing
9568        // rows, so the server-side existence check (§6.6) passes.
9569        if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
9570            if let Err(TransportError { code, message, .. }) = self.flush_blob_uploads(transport) {
9571                if Self::retryable_failure_code(&code) {
9572                    self.schedule_background_retry();
9573                }
9574                return SyncOutcome::Failed {
9575                    details: None,
9576                    error_code: code,
9577                    message,
9578                };
9579            }
9580        }
9581        if let Err(message) = self.drain_pending_evictions() {
9582            return SyncOutcome::Failed {
9583                details: None,
9584                error_code: "storage.failed".into(),
9585                message,
9586            };
9587        }
9588        // §5.11/§10.3: an unresolvable key id or an unknown key is a durable
9589        // per-commit rejection, never a reason to abort the round. Drop those
9590        // commits before the request is built.
9591        if let Err(message) = self.drop_unencodable_outbox() {
9592            return SyncOutcome::Failed {
9593                details: None,
9594                error_code: "storage.failed".into(),
9595                message,
9596            };
9597        }
9598        let (message, meta) = self.build_request(transport.supports_url_fetch());
9599        let request_bytes = {
9600            #[cfg(feature = "bench-internals")]
9601            let _phase = self.benchmark_phases.start(Phase::RequestEncode);
9602            encode_message(&message)
9603        };
9604        // §8.7: rounds ride the socket whenever it is connected (one
9605        // loop, no fallback pair); the transport seam stays bytes-in /
9606        // bytes-out either way. Registration-at-round-end is server-side.
9607        // §8.8: under `required`, a round entered while the socket is not
9608        // connected is refused as a typed state and NEVER falls back to the
9609        // request/response binding.
9610        let round = if self.realtime_state == RealtimeState::Connected {
9611            transport.realtime_sync(&request_bytes)
9612        } else if self.realtime_policy == RealtimePolicy::Required {
9613            let retry_delay_ms = self.schedule_background_retry();
9614            self.realtime_retry_delay_ms = Some(retry_delay_ms);
9615            return SyncOutcome::RealtimeUnavailable {
9616                state: self.realtime_state,
9617                reason_code: self.realtime_reason_code.clone(),
9618                retry_delay_ms,
9619            };
9620        } else {
9621            transport.sync(&request_bytes)
9622        };
9623        let realtime_round = self.realtime_state == RealtimeState::Connected;
9624        let response_bytes = match round {
9625            Ok(bytes) => bytes,
9626            Err(TransportError { code, message, .. }) => {
9627                // §8.8: the socket could not carry the round. Record the loss
9628                // with the transport's code so a later round refuses under
9629                // `required` and reports the explicit state.
9630                if realtime_round {
9631                    self.set_realtime_state(RealtimeState::Lost, Some(&code));
9632                }
9633                // §7.3.5: a request-level lease code stops-and-surfaces —
9634                // record it in leaseState (no local-data purge, §7.3.4).
9635                self.record_lease_error(&code);
9636                if Self::retryable_failure_code(&code) {
9637                    let retry_delay_ms = self.schedule_background_retry();
9638                    if realtime_round {
9639                        self.realtime_retry_delay_ms = Some(retry_delay_ms);
9640                    }
9641                }
9642                return SyncOutcome::Failed {
9643                    details: None,
9644                    error_code: code,
9645                    message,
9646                };
9647            }
9648        };
9649        #[cfg(feature = "bench-internals")]
9650        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9651        let response = match decode_message(&response_bytes) {
9652            Ok(message) => message,
9653            Err(error) => {
9654                // §1.2 rule 1 / §1.4 rule 5: truncated or malformed
9655                // responses abort without persisting anything.
9656                return SyncOutcome::Failed {
9657                    details: None,
9658                    error_code: error.code.as_str().to_owned(),
9659                    message: error.detail,
9660                };
9661            }
9662        };
9663        #[cfg(feature = "bench-internals")]
9664        drop(decode_phase);
9665        if response.msg_kind != MsgKind::Response {
9666            return SyncOutcome::Failed {
9667                details: None,
9668                error_code: "sync.invalid_request".to_owned(),
9669                message: "expected a response message".to_owned(),
9670            };
9671        }
9672        let mut outcome = self.process_response(transport, response, &meta);
9673        if let SyncOutcome::Ok(report) = &mut outcome {
9674            report.deferred_commits = meta.deferred_commits;
9675        }
9676        match &outcome {
9677            SyncOutcome::Ok(_) => self.reset_background_retry(),
9678            SyncOutcome::Failed { error_code, .. } if Self::retryable_failure_code(error_code) => {
9679                self.schedule_background_retry();
9680            }
9681            SyncOutcome::Failed { .. } => {}
9682            SyncOutcome::RealtimeUnavailable { .. } => {}
9683        }
9684        if meta.deferred_commits > 0 {
9685            // §6.1 splitBatch: commits past the operation cap wait for the
9686            // next round — keep the host's sync signal raised until then.
9687            self.set_sync_needed(true, true);
9688        }
9689        // RFC 0005 D7: coverage completion, lease end, scope revocation and the
9690        // TTL are evaluated once per round, after the apply.
9691        self.previous_version_lifetime_check();
9692        outcome
9693    }
9694
9695    pub fn sync_until_idle(
9696        &mut self,
9697        transport: &mut dyn Transport,
9698        max_rounds: Option<u32>,
9699    ) -> SyncOutcome {
9700        let rounds = max_rounds.unwrap_or(20).max(1);
9701        let mut aggregate = SyncReport::default();
9702        let mut spent = 0;
9703        while spent < rounds {
9704            spent += 1;
9705            let before = self
9706                .subs
9707                .iter()
9708                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9709                .collect::<Vec<_>>();
9710            let outcome = self.sync(transport);
9711            match outcome {
9712                SyncOutcome::Failed {
9713                    error_code,
9714                    message,
9715                    details,
9716                } => {
9717                    return SyncOutcome::Failed {
9718                        details,
9719                        error_code,
9720                        message,
9721                    };
9722                }
9723                SyncOutcome::RealtimeUnavailable { .. } => return outcome,
9724                SyncOutcome::Ok(report) => {
9725                    if max_rounds.is_none()
9726                        && report.segment_rows_applied > 0
9727                        && !report.bootstrapping.is_empty()
9728                        && before
9729                            != self
9730                                .subs
9731                                .iter()
9732                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9733                                .collect::<Vec<_>>()
9734                    {
9735                        spent = 0;
9736                    }
9737                    aggregate.pushed += report.pushed;
9738                    aggregate.applied.extend(report.applied.iter().cloned());
9739                    aggregate.rejected.extend(report.rejected.iter().cloned());
9740                    aggregate.retryable.extend(report.retryable.iter().cloned());
9741                    aggregate.conflicts += report.conflicts;
9742                    aggregate.commits_applied += report.commits_applied;
9743                    aggregate.segment_rows_applied += report.segment_rows_applied;
9744                    aggregate.bootstrapping = report.bootstrapping.clone();
9745                    aggregate.resets.extend(report.resets.iter().cloned());
9746                    aggregate.revoked.extend(report.revoked.iter().cloned());
9747                    aggregate.failed.extend(report.failed.iter().cloned());
9748                    aggregate.deferred_commits = report.deferred_commits;
9749                    if report.schema_floor.is_some() {
9750                        aggregate.schema_floor = report.schema_floor.clone();
9751                    }
9752                    // §4.5: pull again whenever the response contained
9753                    // commits or segments; resets re-bootstrap; a pending
9754                    // resume token continues paging (§4.7); a raised
9755                    // sync-needed signal covers §6.1 splitBatch remainders
9756                    // (deferred outbox commits push on the next round).
9757                    let more = !report.bootstrapping.is_empty()
9758                        || report.commits_applied > 0
9759                        || report.segment_rows_applied > 0
9760                        || !report.resets.is_empty()
9761                        || self.sync_needed;
9762                    if !more {
9763                        return SyncOutcome::Ok(aggregate);
9764                    }
9765                }
9766            }
9767        }
9768        SyncOutcome::Failed {
9769            details: None,
9770            error_code: "sync.invalid_request".into(),
9771            message: "sync did not reach idle within the round budget".into(),
9772        }
9773    }
9774
9775    fn process_response(
9776        &mut self,
9777        transport: &mut dyn Transport,
9778        response: Message,
9779        meta: &RequestMeta,
9780    ) -> SyncOutcome {
9781        #[cfg(feature = "bench-internals")]
9782        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
9783        let mut report = SyncReport {
9784            pushed: meta.pushed_ids.len() as u32,
9785            ..SyncReport::default()
9786        };
9787        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
9788            HashMap::new();
9789        let pushed_ids = meta
9790            .pushed_ids
9791            .iter()
9792            .map(String::as_str)
9793            .collect::<HashSet<_>>();
9794        let mut last_final_push_result_id: Option<String> = None;
9795        for frame in &response.frames {
9796            match frame {
9797                Frame::PushResultDetails {
9798                    client_commit_id,
9799                    entries,
9800                } => {
9801                    let details = rejection_details_by_commit
9802                        .entry(client_commit_id.clone())
9803                        .or_default();
9804                    for entry in entries {
9805                        let parsed = match RejectionDetails::parse(&entry.details.0) {
9806                            Ok(value) => value,
9807                            Err(message) => {
9808                                return SyncOutcome::Failed {
9809                                    details: None,
9810                                    error_code: "sync.invalid_request".to_owned(),
9811                                    message,
9812                                };
9813                            }
9814                        };
9815                        details.insert(entry.op_index, parsed);
9816                    }
9817                }
9818                Frame::PushResult {
9819                    client_commit_id,
9820                    status,
9821                    results,
9822                    ..
9823                } if pushed_ids.contains(client_commit_id.as_str())
9824                    && Self::push_result_is_final(*status, results) =>
9825                {
9826                    last_final_push_result_id = Some(client_commit_id.clone());
9827                }
9828                _ => {}
9829            }
9830        }
9831        let mut frames = response.frames.into_iter().peekable();
9832        if response.wire_version < 2 {
9833            return SyncOutcome::Failed {
9834                details: None,
9835                error_code: "client.invalid_host_response".to_owned(),
9836                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
9837            };
9838        }
9839        match frames.next() {
9840            Some(Frame::RespHeader {
9841                required_schema_version,
9842                latest_schema_version,
9843                log_epoch,
9844                reset_required,
9845            }) => {
9846                let Some(log_epoch) = log_epoch else {
9847                    return SyncOutcome::Failed {
9848                        details: None,
9849                        error_code: "client.invalid_host_response".to_owned(),
9850                        message: "response header omits logEpoch".to_owned(),
9851                    };
9852                };
9853                let Some(reset_required) = reset_required else {
9854                    return SyncOutcome::Failed {
9855                        details: None,
9856                        error_code: "client.invalid_host_response".to_owned(),
9857                        message: "response header omits resetRequired".to_owned(),
9858                    };
9859                };
9860                if let Some(required) = required_schema_version {
9861                    // §1.6 schema-floor response: nothing else is processed;
9862                    // stop syncing and surface the upgrade requirement.
9863                    let floor = SchemaFloor {
9864                        required_schema_version: Some(required),
9865                        latest_schema_version,
9866                    };
9867                    self.set_schema_floor(Some(floor.clone()));
9868                    report.schema_floor = Some(floor);
9869                    return SyncOutcome::Ok(report);
9870                }
9871                if reset_required {
9872                    if frames.next().is_some() {
9873                        return SyncOutcome::Failed {
9874                            details: None,
9875                            error_code: "client.invalid_host_response".to_owned(),
9876                            message: "log-epoch reset response contains body frames".to_owned(),
9877                        };
9878                    }
9879                    match self.run_log_epoch_reset(&log_epoch) {
9880                        Ok(resets) => {
9881                            report.resets = resets;
9882                            return SyncOutcome::Ok(report);
9883                        }
9884                        Err(message) => {
9885                            return SyncOutcome::Failed {
9886                                details: None,
9887                                error_code: "sync.local_corrupt".to_owned(),
9888                                message,
9889                            };
9890                        }
9891                    }
9892                }
9893                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
9894                    return SyncOutcome::Failed {
9895                        details: None,
9896                        error_code: "client.invalid_host_response".to_owned(),
9897                        message: "server changed logEpoch without requiring a reset".to_owned(),
9898                    };
9899                }
9900            }
9901            _ => {
9902                return SyncOutcome::Failed {
9903                    details: None,
9904                    error_code: "sync.invalid_request".to_owned(),
9905                    message: "response does not start with RESP_HEADER".to_owned(),
9906                };
9907            }
9908        }
9909
9910        let mut failure: Option<(String, String)> = None;
9911        while let Some(frame) = frames.next() {
9912            match frame {
9913                frame @ Frame::PushResult { .. } => {
9914                    let successful = matches!(
9915                        &frame,
9916                        Frame::PushResult {
9917                            status: PushStatus::Applied | PushStatus::Cached,
9918                            ..
9919                        }
9920                    );
9921                    let mut results = vec![frame];
9922                    if successful {
9923                        while matches!(
9924                            frames.peek(),
9925                            Some(Frame::PushResult {
9926                                status: PushStatus::Applied | PushStatus::Cached,
9927                                ..
9928                            })
9929                        ) {
9930                            if let Some(next) = frames.next() {
9931                                results.push(next);
9932                            }
9933                        }
9934                    }
9935                    if self
9936                        .handle_push_results(
9937                            &results,
9938                            &pushed_ids,
9939                            &rejection_details_by_commit,
9940                            &mut report,
9941                            last_final_push_result_id.as_deref(),
9942                        )
9943                        .is_err()
9944                    {
9945                        failure = Some((
9946                            "client.outcome_persistence_failed".to_owned(),
9947                            "local commit outcome could not be persisted".to_owned(),
9948                        ));
9949                        break;
9950                    }
9951                }
9952                Frame::PushResultDetails { .. } => {}
9953                Frame::SubStart {
9954                    id,
9955                    status,
9956                    reason_code,
9957                    effective_scopes,
9958                    bootstrap: _,
9959                } => {
9960                    let mut body = Vec::new();
9961                    let mut sub_end: Option<(i64, Option<String>)> = None;
9962                    for inner in frames.by_ref() {
9963                        match inner {
9964                            Frame::SubEnd {
9965                                next_cursor,
9966                                bootstrap_state,
9967                            } => {
9968                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
9969                                break;
9970                            }
9971                            Frame::Unknown { .. } => {}
9972                            other => body.push(other),
9973                        }
9974                    }
9975                    if let Err(SectionError::Abort(code, message)) = self.process_section(
9976                        transport,
9977                        &id,
9978                        status,
9979                        &reason_code,
9980                        effective_scopes,
9981                        body,
9982                        sub_end,
9983                        meta,
9984                        &mut report,
9985                    ) {
9986                        failure = Some((code, message));
9987                        break;
9988                    }
9989                }
9990                Frame::Lease {
9991                    lease_id,
9992                    expires_at_ms,
9993                } => {
9994                    // §7.3.5: persist the opaque lease; a fresh lease clears
9995                    // any prior lease error (the outage/revocation is over).
9996                    self.set_lease_state(Some(LeaseState {
9997                        lease_id: Some(lease_id),
9998                        expires_at_ms: Some(expires_at_ms),
9999                        error_code: None,
10000                    }));
10001                }
10002                Frame::Error { code, message, .. } => {
10003                    // §1.6: the whole request failed; already-completed
10004                    // subscriptions keep their applied data and cursors.
10005                    failure = Some((code, message));
10006                    break;
10007                }
10008                Frame::Unknown { .. } => {}
10009                _ => {
10010                    failure = Some((
10011                        "sync.invalid_request".to_owned(),
10012                        "unexpected frame in response".to_owned(),
10013                    ));
10014                    break;
10015                }
10016            }
10017        }
10018
10019        // §7.1: reconcile the visible overlay once at the response boundary
10020        // after all outbox acknowledgements and server data applied so a
10021        // batch of PUSH_RESULT frames cannot trigger repeated full-table
10022        // rebuilds. This also covers a round that aborted mid-way.
10023        if self.overlay_dirty.get() {
10024            self.rebuild_overlay();
10025        }
10026        if let Some((error_code, message)) = failure {
10027            return SyncOutcome::Failed {
10028                details: None,
10029                error_code,
10030                message,
10031            };
10032        }
10033        // §4.8 E1: the push half may have drained commits that pinned rows of
10034        // a shrunk window unit — retry any deferred evictions now.
10035        if let Err(message) = self.drain_pending_evictions() {
10036            return SyncOutcome::Failed {
10037                details: None,
10038                error_code: "storage.failed".into(),
10039                message,
10040            };
10041        }
10042        self.ack_after_pull(transport);
10043        // §7.4.5: the reset is over once the first post-reset pull round
10044        // leaves no subscription mid-bootstrap — the tables are rebuilt.
10045        if self.upgrading && report.bootstrapping.is_empty() {
10046            self.set_upgrading(false);
10047        }
10048        SyncOutcome::Ok(report)
10049    }
10050
10051    // -- push results (§6.3, §7.2) ------------------------------------------------
10052
10053    fn handle_push_results(
10054        &mut self,
10055        frames: &[Frame],
10056        pushed_ids: &HashSet<&str>,
10057        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
10058        report: &mut SyncReport,
10059        last_final_push_result_id: Option<&str>,
10060    ) -> Result<(), String> {
10061        self.begin_observation("syncular_push_results")?;
10062        let previous_failed = self.failed_commits.clone();
10063        let conflict_count = self.conflicts.len();
10064        let rejection_count = self.rejections.len();
10065        let applied_count = report.applied.len();
10066        let rejected_count = report.rejected.len();
10067        let retryable_count = report.retryable.len();
10068        let was_dirty = self.overlay_dirty.get();
10069        let mut removed = Vec::new();
10070        let mut batch = ChangeAccumulator::default();
10071        let persisted = (|| {
10072            for frame in frames {
10073                let Frame::PushResult {
10074                    client_commit_id,
10075                    status,
10076                    results,
10077                    ..
10078                } = frame
10079                else {
10080                    return Err("unexpected acknowledgement frame".to_owned());
10081                };
10082                if !pushed_ids.contains(client_commit_id.as_str()) {
10083                    continue;
10084                }
10085                let Some(index) = self
10086                    .outbox
10087                    .iter()
10088                    .position(|commit| commit.client_commit_id == *client_commit_id)
10089                else {
10090                    continue;
10091                };
10092                let status = *status;
10093                let rejection_details = rejection_details_by_commit.get(client_commit_id);
10094                let operations = self.outbox[index].ops.clone();
10095                match status {
10096                    PushStatus::Applied | PushStatus::Cached => {
10097                        // §7.2: a lost ack replays as `cached` — proceed as if the
10098                        // ack had arrived.
10099                        let journal_results = results
10100                            .iter()
10101                            .map(|result| {
10102                                let op_index = match result {
10103                                    OpResult::Applied { op_index }
10104                                    | OpResult::Conflict { op_index, .. }
10105                                    | OpResult::Error { op_index, .. } => *op_index,
10106                                };
10107                                CommitOperationOutcome::Applied { op_index }
10108                            })
10109                            .collect::<Vec<_>>();
10110                        let outcome_status = if status == PushStatus::Applied {
10111                            CommitOutcomeStatus::Applied
10112                        } else {
10113                            CommitOutcomeStatus::Cached
10114                        };
10115                        let persisted = self
10116                            .persist_commit_outcome(
10117                                client_commit_id,
10118                                outcome_status,
10119                                &journal_results,
10120                                None,
10121                            )
10122                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10123                        persisted?;
10124                        self.overlay_dirty.set(true);
10125                        batch.status = true;
10126                        batch.outcomes = true;
10127                    }
10128                    PushStatus::Rejected => {
10129                        if results.iter().any(|result| {
10130                            matches!(
10131                                result,
10132                                OpResult::Error {
10133                                    code,
10134                                    retryable: true,
10135                                    ..
10136                                } if code == "sync.idempotency_cache_miss"
10137                            )
10138                        }) {
10139                            // §6.3/§7.2: a serving failure, not an outcome — keep the
10140                            // exact commit queued for an identical retry.
10141                            report.retryable.push(client_commit_id.to_owned());
10142                            continue;
10143                        }
10144
10145                        let mut journal_results = Vec::with_capacity(results.len());
10146                        let mut conflicts = Vec::new();
10147                        let mut rejections = Vec::new();
10148                        for result in results {
10149                            match result {
10150                                OpResult::Applied { op_index } => {
10151                                    journal_results.push(CommitOperationOutcome::Applied {
10152                                        op_index: *op_index,
10153                                    });
10154                                }
10155                                OpResult::Conflict {
10156                                    op_index,
10157                                    code,
10158                                    message,
10159                                    server_version,
10160                                    server_row,
10161                                    conflict_columns: conflict_columns_bitmap,
10162                                } => {
10163                                    let operation = operations
10164                                        .get(*op_index as usize)
10165                                        .map(CommitOperation::from);
10166                                    let (table, row_id) = operation
10167                                        .as_ref()
10168                                        .map(|op| (op.table.clone(), op.row_id.clone()))
10169                                        .unwrap_or_default();
10170                                    let server_row_json = self
10171                                        .schema
10172                                        .table(&table)
10173                                        .and_then(|t| {
10174                                            decode_row_bytes(t, server_row, &self.encryption)
10175                                                .ok()
10176                                                .map(|row| (t, row))
10177                                        })
10178                                        .map(|(t, row)| {
10179                                            let mut map = Map::new();
10180                                            for (i, column) in t.columns.iter().enumerate() {
10181                                                map.insert(
10182                                                    column.name.clone(),
10183                                                    column_value_to_json(
10184                                                        row.get(i).unwrap_or(&None),
10185                                                    ),
10186                                                );
10187                                            }
10188                                            map
10189                                        })
10190                                        .unwrap_or_default();
10191                                    // §6.3: decode the presence-layout bitmap
10192                                    // into the marked column names.
10193                                    let conflict_columns = self
10194                                        .schema
10195                                        .table(&table)
10196                                        .map(|t| {
10197                                            t.columns
10198                                                .iter()
10199                                                .enumerate()
10200                                                .filter(|(index, _)| {
10201                                                    conflict_columns_bitmap
10202                                                        .get(index / 8)
10203                                                        .is_some_and(|byte| {
10204                                                            byte & (1 << (index % 8)) != 0
10205                                                        })
10206                                                })
10207                                                .map(|(_, column)| column.name.clone())
10208                                                .collect::<Vec<_>>()
10209                                        })
10210                                        .unwrap_or_default();
10211                                    let conflict = ConflictRecord {
10212                                        client_commit_id: client_commit_id.to_owned(),
10213                                        op_index: *op_index,
10214                                        table,
10215                                        row_id,
10216                                        code: code.clone(),
10217                                        message: message.clone(),
10218                                        server_version: *server_version,
10219                                        server_row: server_row_json,
10220                                        conflict_columns,
10221                                        operation,
10222                                    };
10223                                    journal_results.push(CommitOperationOutcome::Conflict {
10224                                        conflict: conflict.clone(),
10225                                    });
10226                                    conflicts.push(conflict);
10227                                }
10228                                OpResult::Error {
10229                                    op_index,
10230                                    code,
10231                                    message,
10232                                    retryable,
10233                                } => {
10234                                    let rejection = RejectionRecord {
10235                                        client_commit_id: client_commit_id.to_owned(),
10236                                        op_index: *op_index,
10237                                        code: code.clone(),
10238                                        message: message.clone(),
10239                                        retryable: *retryable,
10240                                        details: rejection_details
10241                                            .and_then(|details| details.get(op_index))
10242                                            .cloned(),
10243                                        operation: operations
10244                                            .get(*op_index as usize)
10245                                            .map(CommitOperation::from),
10246                                    };
10247                                    journal_results.push(CommitOperationOutcome::Error {
10248                                        rejection: rejection.clone(),
10249                                    });
10250                                    rejections.push(rejection);
10251                                }
10252                            }
10253                        }
10254                        if self.retain_failed_commits {
10255                            self.retain_failed_commit(client_commit_id, &operations)?;
10256                        }
10257                        let outcome_status = if conflicts.is_empty() {
10258                            CommitOutcomeStatus::Rejected
10259                        } else {
10260                            CommitOutcomeStatus::Conflict
10261                        };
10262                        let persisted = self
10263                            .persist_commit_outcome(
10264                                client_commit_id,
10265                                outcome_status,
10266                                &journal_results,
10267                                Some(&operations),
10268                            )
10269                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10270                        persisted?;
10271                        batch.conflicts = !conflicts.is_empty();
10272                        batch.rejections = !rejections.is_empty();
10273                        batch.status = true;
10274                        batch.outcomes = true;
10275                        self.conflicts.extend(conflicts);
10276                        self.rejections.extend(rejections);
10277                        self.overlay_dirty.set(true);
10278                    }
10279                }
10280                removed.push((index, self.outbox.remove(index)));
10281                if batch.status {
10282                    for operation in &operations {
10283                        if !self.record_row_scopes(
10284                            &mut batch,
10285                            &operation.table,
10286                            &operation.row_id,
10287                            false,
10288                        ) {
10289                            batch.table(&operation.table);
10290                        }
10291                    }
10292                }
10293                match status {
10294                    PushStatus::Applied | PushStatus::Cached => {
10295                        report.applied.push(client_commit_id.to_owned())
10296                    }
10297                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
10298                }
10299            }
10300            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)) {
10301                self.prune_commit_outcomes()?;
10302            }
10303            self.finish_observation("syncular_push_results", batch)
10304        })();
10305        if let Err(error) = persisted {
10306            self.rollback_observation("syncular_push_results");
10307            self.failed_commits = previous_failed;
10308            for (index, commit) in removed.into_iter().rev() {
10309                self.outbox.insert(index, commit);
10310            }
10311            self.conflicts.truncate(conflict_count);
10312            self.rejections.truncate(rejection_count);
10313            self.overlay_dirty.set(was_dirty);
10314            report.applied.truncate(applied_count);
10315            report.rejected.truncate(rejected_count);
10316            report.retryable.truncate(retryable_count);
10317            return Err(error);
10318        }
10319        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
10320        Ok(())
10321    }
10322
10323    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
10324        status != PushStatus::Rejected
10325            || !results.iter().any(|result| {
10326                matches!(
10327                    result,
10328                    OpResult::Error {
10329                        code,
10330                        retryable: true,
10331                        ..
10332                    } if code == "sync.idempotency_cache_miss"
10333                )
10334            })
10335    }
10336
10337    // -- subscription sections ------------------------------------------------------
10338
10339    #[allow(clippy::too_many_arguments)]
10340    fn process_section(
10341        &mut self,
10342        transport: &mut dyn Transport,
10343        id: &str,
10344        status: SubStatus,
10345        reason_code: &str,
10346        effective_scopes: Vec<(String, Vec<String>)>,
10347        body: Vec<Frame>,
10348        sub_end: Option<(i64, Option<String>)>,
10349        meta: &RequestMeta,
10350        report: &mut SyncReport,
10351    ) -> Result<(), SectionError> {
10352        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
10353            return Ok(()); // unknown echo: ignore
10354        };
10355        if status != SubStatus::Active && sub_end.is_none() {
10356            return Err(SectionError::Abort(
10357                "sync.invalid_request".into(),
10358                "subscription section without SUB_END".into(),
10359            ));
10360        }
10361        match status {
10362            SubStatus::Revoked => {
10363                self.begin_observation("syncular_revocation")
10364                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10365                let previous = self.subs[sub_index].clone();
10366                let was_dirty = self.overlay_dirty.get();
10367                let previous_outbox = self.outbox.clone();
10368                let previous_failed = self.failed_commits.clone();
10369                let rejection_count = self.rejections.len();
10370                let revoked_count = report.revoked.len();
10371                let failed_count = report.failed.len();
10372                let result =
10373                    (|| {
10374                        let mut batch = ChangeAccumulator::default();
10375                        let registered = self.window_unit_by_sub_id(id);
10376                        // §3.3: stop pulling, purge exactly the last effective grant.
10377                        let (table, effective) = {
10378                            let sub = &self.subs[sub_index];
10379                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10380                        };
10381                        let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
10382                            SectionError::Abort(
10383                                "sync.unknown_table".to_owned(),
10384                                "revoked scope references an unknown table".to_owned(),
10385                            )
10386                        })?;
10387                        let dropped = self
10388                            .drop_failed_commits(|name, values| {
10389                                name == table
10390                                    && !effective.is_empty()
10391                                    && effective.iter().all(|(variable, allowed)| {
10392                                        scoped_table
10393                                            .scope_column(variable)
10394                                            .and_then(|column| values.get(column))
10395                                            .and_then(Value::as_str)
10396                                            .is_some_and(|value| {
10397                                                allowed.iter().any(|allowed| allowed == value)
10398                                            })
10399                                    })
10400                            })
10401                            .map_err(|message| {
10402                                SectionError::Abort("storage.failed".to_owned(), message)
10403                            })?;
10404                        for commit in dropped {
10405                            batch.outcomes = true;
10406                            for operation in commit.ops {
10407                                batch.table(&operation.table);
10408                            }
10409                        }
10410                        let purged = self.purge_scope_rows(&table, &effective);
10411                        match purged {
10412                            Ok(()) => {
10413                                self.record_scope_map(&mut batch, &table, &effective);
10414                                let sub = &mut self.subs[sub_index];
10415                                sub.state = SubState::Revoked;
10416                                sub.reason_code = Some(if reason_code.is_empty() {
10417                                    "sync.scope_revoked".to_owned()
10418                                } else {
10419                                    reason_code.to_owned()
10420                                });
10421                                report.revoked.push(id.to_owned());
10422                                let doomed_effective = effective;
10423                                let sub_table = table;
10424                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
10425                                    |message| SectionError::Abort("storage.failed".into(), message),
10426                                )?;
10427                                let dropped = self
10428                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
10429                                    .map_err(|message| {
10430                                        SectionError::Abort("storage.failed".to_owned(), message)
10431                                    })?;
10432                                if dropped {
10433                                    batch.status = true;
10434                                    batch.rejections = true;
10435                                    batch.outcomes = true;
10436                                }
10437                                // §5.9.7 B2: revocation deletes now-unauthorized blob
10438                                // bodies (evicted ≠ revoked).
10439                                self.delete_unreferenced_cached_blobs();
10440                            }
10441                            Err(()) => {
10442                                // §3.3 fail closed: no local mapping — never clear by
10443                                // approximation; fatal configuration error.
10444                                let sub = &mut self.subs[sub_index];
10445                                sub.state = SubState::Failed;
10446                                sub.reason_code = Some("sync.scope_revoked".to_owned());
10447                                report.failed.push(id.to_owned());
10448                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
10449                                    |message| SectionError::Abort("storage.failed".into(), message),
10450                                )?;
10451                            }
10452                        }
10453                        if let Some((base_key, unit)) = registered {
10454                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
10455                        }
10456                        self.rebuild_overlay_if_dirty();
10457                        self.finish_observation("syncular_revocation", batch)
10458                            .map_err(|message| {
10459                                SectionError::Abort("storage.failed".to_owned(), message)
10460                            })?;
10461                        Ok(())
10462                    })();
10463                if result.is_err() {
10464                    self.rollback_observation("syncular_revocation");
10465                    self.subs[sub_index] = previous;
10466                    self.overlay_dirty.set(was_dirty);
10467                    self.outbox = previous_outbox;
10468                    self.failed_commits = previous_failed;
10469                    self.rejections.truncate(rejection_count);
10470                    report.revoked.truncate(revoked_count);
10471                    report.failed.truncate(failed_count);
10472                }
10473                result
10474            }
10475            SubStatus::Reset => {
10476                self.begin_observation("syncular_reset")
10477                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10478                let previous = self.subs[sub_index].clone();
10479                let was_dirty = self.overlay_dirty.get();
10480                let reset_count = report.resets.len();
10481                let result = (|| {
10482                    let mut batch = ChangeAccumulator::default();
10483                    let registered = self.window_unit_by_sub_id(id);
10484                    // §4.6: discard cursor + bootstrap state, keep local rows —
10485                    // reset is a staleness signal, not a purge signal.
10486                    let sub = &mut self.subs[sub_index];
10487                    sub.cursor = -1;
10488                    sub.bootstrap_state = None;
10489                    report.resets.push(id.to_owned());
10490                    self.persist_sub(&self.subs[sub_index].clone())
10491                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10492                    if let Some((base_key, unit)) = registered {
10493                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
10494                    }
10495                    self.finish_observation("syncular_reset", batch)
10496                        .map_err(|message| {
10497                            SectionError::Abort("storage.failed".to_owned(), message)
10498                        })?;
10499                    Ok(())
10500                })();
10501                if result.is_err() {
10502                    self.rollback_observation("syncular_reset");
10503                    self.subs[sub_index] = previous;
10504                    self.overlay_dirty.set(was_dirty);
10505                    report.resets.truncate(reset_count);
10506                }
10507                result
10508            }
10509            SubStatus::Active => {
10510                let fresh = meta
10511                    .fresh
10512                    .iter()
10513                    .find(|(fid, _)| fid == id)
10514                    .map(|(_, fresh)| *fresh)
10515                    .unwrap_or(false);
10516                let previous = self.subs[sub_index].clone();
10517                self.subs[sub_index].effective = Some(effective_scopes);
10518                let outcome = self
10519                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
10520                    .and_then(|()| {
10521                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
10522                            SectionError::Abort(
10523                                "sync.invalid_request".into(),
10524                                "subscription section without SUB_END".into(),
10525                            )
10526                        })?;
10527                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
10528                            .map_err(|message| {
10529                                SectionError::Abort("storage.failed".into(), message)
10530                            })
10531                    });
10532                match outcome {
10533                    Ok(()) => {
10534                        if self.subs[sub_index].bootstrap_state.is_some() {
10535                            report.bootstrapping.push(id.to_owned());
10536                        }
10537                        Ok(())
10538                    }
10539                    Err(SectionError::FailClosed) => {
10540                        self.subs[sub_index] = previous.clone();
10541                        self.begin_observation("syncular_section_failure")
10542                            .map_err(|message| {
10543                                SectionError::Abort("storage.failed".into(), message)
10544                            })?;
10545                        let persisted = (|| {
10546                            let mut batch = ChangeAccumulator::default();
10547                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
10548                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
10549                            }
10550                            self.subs[sub_index].state = SubState::Failed;
10551                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
10552                            self.persist_sub(&self.subs[sub_index])?;
10553                            self.finish_observation("syncular_section_failure", batch)
10554                        })();
10555                        if let Err(message) = persisted {
10556                            self.rollback_observation("syncular_section_failure");
10557                            self.subs[sub_index] = previous;
10558                            return Err(SectionError::Abort("storage.failed".into(), message));
10559                        }
10560                        report.failed.push(id.to_owned());
10561                        Ok(())
10562                    }
10563                    Err(error) => {
10564                        // Frame/block transactions already completed. Preserve their
10565                        // rows and revisions, but leave this subscription cursor unchanged.
10566                        self.subs[sub_index] = previous;
10567                        Err(error)
10568                    }
10569                }
10570            }
10571        }
10572    }
10573
10574    fn apply_sub_end(
10575        &mut self,
10576        sub_index: usize,
10577        cursor: i64,
10578        bootstrap_state: Option<String>,
10579    ) -> Result<(), String> {
10580        #[cfg(feature = "bench-internals")]
10581        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
10582        let previous = self.subs[sub_index].clone();
10583        self.begin_observation("syncular_sub_end")?;
10584        let persisted = (|| {
10585            let mut batch = ChangeAccumulator::default();
10586            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
10587                && cursor >= 0
10588                && bootstrap_state.is_none();
10589            if completed {
10590                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
10591                    batch.window(&base_key, &previous.table, &unit);
10592                }
10593            }
10594            let sub = &mut self.subs[sub_index];
10595            sub.cursor = cursor;
10596            sub.bootstrap_state = bootstrap_state;
10597            sub.synced_once = true;
10598            self.persist_sub(&self.subs[sub_index])?;
10599            self.finish_observation("syncular_sub_end", batch)
10600        })();
10601        if persisted.is_err() {
10602            self.rollback_observation("syncular_sub_end");
10603            self.subs[sub_index] = previous;
10604        }
10605        persisted
10606    }
10607
10608    fn apply_section_body(
10609        &mut self,
10610        transport: &mut dyn Transport,
10611        sub_index: usize,
10612        body: Vec<Frame>,
10613        fresh: bool,
10614        meta: &RequestMeta,
10615        report: &mut SyncReport,
10616    ) -> Result<(), SectionError> {
10617        let mut saw_segment = false;
10618        for frame in body {
10619            match frame {
10620                Frame::Commit {
10621                    tables, changes, ..
10622                } => {
10623                    self.apply_commit_frame(&tables, &changes)
10624                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
10625                    report.commits_applied += 1;
10626                }
10627                Frame::SegmentInline { payload } => {
10628                    let segment = decode_rows_segment(&payload)
10629                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
10630                    self.progress.update(|p| {
10631                        p.phase = ProgressPhase::Import;
10632                        p.subscription_id = Some(self.subs[sub_index].id.clone());
10633                        p.table = Some(self.subs[sub_index].table.clone());
10634                        p.segment_id = None;
10635                        p.bytes_received = payload.len() as u64;
10636                        p.bytes_total = Some(payload.len() as u64);
10637                        p.rows_processed = 0;
10638                        p.rows_total =
10639                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
10640                    });
10641                    let first = !saw_segment;
10642                    saw_segment = true;
10643                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10644                    report.segment_rows_applied += applied;
10645                }
10646                Frame::SegmentRef {
10647                    segment_id,
10648                    byte_length,
10649                    media_type,
10650                    table,
10651                    row_count,
10652                    as_of_commit_seq,
10653                    scope_digest,
10654                    row_cursor,
10655                    next_row_cursor,
10656                    url,
10657                    url_expires_at_ms,
10658                    ..
10659                } => {
10660                    // §4.2: reject a descriptor whose mediaType was not
10661                    // advertised — never skip or guess.
10662                    let advertised = match media_type {
10663                        MediaType::Rows => {
10664                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
10665                                || meta.accept & ACCEPT_INLINE_ROWS != 0
10666                        }
10667                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
10668                    };
10669                    if !advertised {
10670                        return Err(SectionError::Abort(
10671                            "sync.invalid_request".to_owned(),
10672                            format!(
10673                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
10674                                media_type.name()
10675                            ),
10676                        ));
10677                    }
10678                    self.progress.update(|p| {
10679                        p.phase = ProgressPhase::Download;
10680                        p.subscription_id = Some(self.subs[sub_index].id.clone());
10681                        p.table = Some(table.clone());
10682                        p.segment_id = Some(segment_id.clone());
10683                        p.bytes_received = 0;
10684                        p.bytes_total = u64::try_from(byte_length).ok();
10685                        p.rows_processed = 0;
10686                        p.rows_total = u64::try_from(row_count).ok();
10687                    });
10688                    let observer = self.progress.clone();
10689                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
10690                    let bytes = if let Some(url) = url {
10691                        // §5.4: a url-carrying descriptor MUST be fetched
10692                        // from that URL; failure invalidates the whole
10693                        // descriptor (no fall-through to §5.5 — re-pull
10694                        // recovers, §1.4 rule 5).
10695                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
10696                            return Err(SectionError::Abort(
10697                                "sync.invalid_request".to_owned(),
10698                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
10699                                    .to_owned(),
10700                            ));
10701                        }
10702                        // §5.4: MUST NOT start a fetch at/past expiry.
10703                        if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10704                            return Err(SectionError::Abort(
10705                                "sync.segment_expired".to_owned(),
10706                                format!(
10707                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
10708                                ),
10709                            ));
10710                        }
10711                        transport
10712                            .fetch_url(&url, &mut on_progress)
10713                            .map_err(|e| SectionError::Abort(e.code, e.message))?
10714                    } else {
10715                        let requested_scopes_json =
10716                            canonical_scope_json(&self.subs[sub_index].requested);
10717                        transport
10718                            .download_segment(
10719                                &SegmentRequest {
10720                                    segment_id: segment_id.clone(),
10721                                    table,
10722                                    requested_scopes_json,
10723                                },
10724                                &mut on_progress,
10725                            )
10726                            .map_err(|e| SectionError::Abort(e.code, e.message))?
10727                    };
10728                    // §5.1: verify the content address before applying.
10729                    on_progress(bytes.len() as u64);
10730                    let digest = Sha256::digest(&bytes);
10731                    let expected = segment_id
10732                        .strip_prefix("sha256:")
10733                        .unwrap_or(segment_id.as_str());
10734                    if bytes_to_hex(&digest) != expected {
10735                        // §5.1: the client discards the segment and re-pulls.
10736                        self.schedule_background_retry();
10737                        return Err(SectionError::Abort(
10738                            "sync.invalid_request".to_owned(),
10739                            "segment bytes do not match the content address (§5.1)".to_owned(),
10740                        ));
10741                    }
10742                    self.progress.update(|p| p.phase = ProgressPhase::Import);
10743                    if media_type == MediaType::Sqlite {
10744                        // §5.3: images are whole-table — a paged sqlite
10745                        // descriptor is invalid.
10746                        if row_cursor.is_some() || next_row_cursor.is_some() {
10747                            return Err(SectionError::Abort(
10748                                "sync.invalid_request".to_owned(),
10749                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
10750                                    .to_owned(),
10751                            ));
10752                        }
10753                        let first = !saw_segment;
10754                        saw_segment = true;
10755                        let applied = self.apply_sqlite_segment(
10756                            sub_index,
10757                            &bytes,
10758                            fresh && first,
10759                            row_count,
10760                            as_of_commit_seq,
10761                            &scope_digest,
10762                        )?;
10763                        report.segment_rows_applied += applied;
10764                    } else {
10765                        let segment = decode_rows_segment(&bytes).map_err(|e| {
10766                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
10767                        })?;
10768                        let first = row_cursor.is_none();
10769                        saw_segment = true;
10770                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10771                        report.segment_rows_applied += applied;
10772                    }
10773                }
10774                Frame::Unknown { .. } => {}
10775                Frame::Error { code, message, .. } => {
10776                    return Err(SectionError::Abort(code, message))
10777                }
10778                _ => {
10779                    return Err(SectionError::Abort(
10780                        "sync.invalid_request".to_owned(),
10781                        "unexpected frame inside a subscription section".to_owned(),
10782                    ));
10783                }
10784            }
10785        }
10786        Ok(())
10787    }
10788
10789    // Both pull and realtime frames use the same durable observation boundary.
10790    fn apply_commit_frame(
10791        &mut self,
10792        tables: &[String],
10793        changes: &[ssp2::model::Change],
10794    ) -> Result<(), (String, String)> {
10795        #[cfg(feature = "bench-internals")]
10796        let _phase = self.benchmark_phases.start(Phase::CommitApply);
10797        let was_dirty = self.overlay_dirty.get();
10798        self.begin_observation("syncular_commit")
10799            .map_err(|message| ("storage.failed".into(), message))?;
10800        let applied = (|| {
10801            let mut batch = ChangeAccumulator::default();
10802            self.record_commit_changes(&mut batch, tables, changes);
10803            self.apply_commit_changes(tables, changes)?;
10804            self.rebuild_overlay_if_dirty();
10805            self.finish_observation("syncular_commit", batch)
10806                .map_err(|message| ("storage.failed".into(), message))
10807        })();
10808        if applied.is_err() {
10809            self.rollback_observation("syncular_commit");
10810            self.overlay_dirty.set(was_dirty);
10811        }
10812        applied
10813    }
10814
10815    fn apply_commit_changes(
10816        &mut self,
10817        tables: &[String],
10818        changes: &[ssp2::model::Change],
10819    ) -> Result<(), (String, String)> {
10820        // A clean overlay can reconcile changed primary keys independently
10821        // when their tables have no secondary unique constraints. Such a
10822        // constraint can change whether another row's pending write succeeds,
10823        // so those frames still require the complete FIFO replay below.
10824        let mirror_visible = !self.overlay_dirty.get()
10825            && self.failed_commits.is_empty()
10826            && (self.outbox.is_empty()
10827                || changes.iter().all(|change| {
10828                    tables
10829                        .get(change.table_index as usize)
10830                        .and_then(|name| self.schema.table(name))
10831                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
10832                }));
10833        for change in changes {
10834            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
10835                (
10836                    "sync.invalid_request".to_owned(),
10837                    "change tableIndex out of range".to_owned(),
10838                )
10839            })?;
10840            let table = self.schema.table(table_name).ok_or_else(|| {
10841                (
10842                    "sync.schema_mismatch".to_owned(),
10843                    format!("change targets unknown table {table_name:?}"),
10844                )
10845            })?;
10846            match change.op {
10847                Op::Upsert => {
10848                    let payload = change.row.as_ref().ok_or_else(|| {
10849                        (
10850                            "sync.invalid_request".to_owned(),
10851                            "upsert change without row payload".to_owned(),
10852                        )
10853                    })?;
10854                    // §5.11: decrypt encrypted columns on apply.
10855                    let row = {
10856                        #[cfg(feature = "bench-internals")]
10857                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
10858                        decode_row_bytes(table, payload, &self.encryption)
10859                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
10860                    };
10861                    let version = change.row_version.unwrap_or(0);
10862                    let table_name = table.name.clone();
10863                    self.write_base_row(&table_name, &row, version)
10864                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10865                    if mirror_visible {
10866                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
10867                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10868                    }
10869                }
10870                Op::Delete => {
10871                    self.delete_base_row(table_name, &change.row_id)
10872                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10873                    if mirror_visible {
10874                        let sql = format!(
10875                            "DELETE FROM {} WHERE {}",
10876                            visible_table(table_name),
10877                            row_id_predicate(table)
10878                        );
10879                        self.conn
10880                            .prepare_cached(&sql)
10881                            .and_then(|mut statement| {
10882                                statement.execute(rusqlite::params![change.row_id])
10883                            })
10884                            .map_err(|error| {
10885                                ("sync.invalid_request".to_owned(), error.to_string())
10886                            })?;
10887                    }
10888                }
10889            }
10890        }
10891        if mirror_visible {
10892            if !self.outbox.is_empty() {
10893                let changed_rows: HashSet<(&str, &str)> = changes
10894                    .iter()
10895                    .map(|change| {
10896                        (
10897                            tables[change.table_index as usize].as_str(),
10898                            change.row_id.as_str(),
10899                        )
10900                    })
10901                    .collect();
10902                self.apply_outbox_ops(
10903                    self.outbox
10904                        .iter()
10905                        .flat_map(|commit| &commit.ops)
10906                        .filter(|op| {
10907                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
10908                        }),
10909                );
10910            }
10911            self.overlay_dirty.set(false);
10912        }
10913        Ok(())
10914    }
10915
10916    /// §5.6 segment application: validate against the generated schema,
10917    /// clear the grant on a fresh bootstrap's first page (fail closed
10918    /// without a mapping), then replace-or-upsert each row with its
10919    /// segment-carried server version (§5.2).
10920    fn apply_segment(
10921        &mut self,
10922        sub_index: usize,
10923        segment: &RowsSegment,
10924        first_fresh_page: bool,
10925    ) -> Result<u32, SectionError> {
10926        let (sub_table, effective) = {
10927            let sub = &self.subs[sub_index];
10928            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10929        };
10930        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10931            SectionError::Abort(
10932                "sync.schema_mismatch".to_owned(),
10933                format!("subscription table {sub_table:?} is not in the client schema"),
10934            )
10935        })?;
10936        // §5.2: the column table validates against the generated schema —
10937        // order, names, types, nullability; mismatch is fatal. §5.11: the
10938        // server sends the WIRE types (bytes for an encrypted column), so
10939        // validate against wire_columns.
10940        let matches = segment.table == table.name
10941            && segment.schema_version == self.schema.version
10942            && segment.columns.len() == table.wire_columns.len()
10943            && segment
10944                .columns
10945                .iter()
10946                .zip(table.wire_columns.iter())
10947                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
10948        if !matches {
10949            return Err(SectionError::Abort(
10950                "sync.schema_mismatch".to_owned(),
10951                "segment column table does not match the generated schema (§5.2)".to_owned(),
10952            ));
10953        }
10954        let mut applied = 0u32;
10955        let empty = Vec::new();
10956        let blocks = if segment.blocks.is_empty() {
10957            std::slice::from_ref(&empty)
10958        } else {
10959            &segment.blocks
10960        };
10961        for (index, block) in blocks.iter().enumerate() {
10962            let clear = first_fresh_page && index == 0;
10963            if !clear && block.is_empty() {
10964                continue;
10965            }
10966            let was_dirty = self.overlay_dirty.get();
10967            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10968                && self
10969                    .outbox
10970                    .iter()
10971                    .flat_map(|commit| &commit.ops)
10972                    .any(|op| op.table == table.name);
10973            let mirror_visible = !reconcile_pending && self.failed_commits.is_empty();
10974            self.begin_observation("syncular_segment_block")
10975                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10976            let outcome = (|| {
10977                self.rebuild_overlay_if_dirty();
10978                let mut changed_keys = Vec::new();
10979                let mut batch = ChangeAccumulator::default();
10980                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
10981                    batch.table(&table.name);
10982                }
10983                if clear {
10984                    self.purge_scope_rows(&table.name, &effective)
10985                        .map_err(|()| SectionError::FailClosed)?;
10986                }
10987                for (row_index, row) in block.iter().enumerate() {
10988                    let decrypted;
10989                    let values = if table.has_encrypted_columns() {
10990                        let mut values = row.values.clone();
10991                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
10992                            .map_err(|message| {
10993                            SectionError::Abort("client.decrypt_failed".into(), message)
10994                        })?;
10995                        decrypted = values;
10996                        &decrypted
10997                    } else {
10998                        &row.values
10999                    };
11000                    self.write_base_row(&table.name, values, row.server_version)
11001                        .map_err(|message| {
11002                            SectionError::Abort("sync.invalid_request".into(), message)
11003                        })?;
11004                    if reconcile_pending {
11005                        changed_keys.push(
11006                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
11007                                |message| SectionError::Abort("storage.failed".into(), message),
11008                            )?,
11009                        );
11010                    }
11011                    if mirror_visible {
11012                        self.write_row(
11013                            &visible_table(&table.name),
11014                            &table.name,
11015                            values,
11016                            row.server_version,
11017                        )
11018                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11019                    }
11020                    let processed = u64::from(applied) + row_index as u64 + 1;
11021                    if processed.is_multiple_of(1024) {
11022                        self.progress.update(|p| p.rows_processed = processed);
11023                    }
11024                }
11025                if reconcile_pending {
11026                    self.reconcile_imported_rows(&table, &mut changed_keys)
11027                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11028                }
11029                if self.failed_commits.is_empty() {
11030                    self.apply_outbox_ops(
11031                        self.outbox
11032                            .iter()
11033                            .flat_map(|commit| &commit.ops)
11034                            .filter(|op| op.table == table.name),
11035                    );
11036                    self.overlay_dirty.set(false);
11037                } else {
11038                    self.rebuild_overlay();
11039                }
11040                self.finish_observation("syncular_segment_block", batch)
11041                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
11042            })();
11043            if let Err(error) = outcome {
11044                self.rollback_observation("syncular_segment_block");
11045                self.overlay_dirty.set(was_dirty);
11046                return Err(error);
11047            }
11048            applied += block.len() as u32;
11049            self.progress
11050                .update(|p| p.rows_processed = u64::from(applied));
11051            std::thread::yield_now();
11052        }
11053        self.progress
11054            .update(|p| p.rows_processed = u64::from(applied));
11055        Ok(applied)
11056    }
11057
11058    /// §5.3 sqlite-image application: validate the in-file metadata
11059    /// against the descriptor, validate column names/order against the
11060    /// generated schema, run the §5.6 first-page clear when fresh, then
11061    /// replace-or-upsert every image row with its `_syncular_version`.
11062    /// Mechanics: the image lands in a temp file read through a second
11063    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
11064    /// Each image chunk commits its writes and revision before readers continue.
11065    fn apply_sqlite_segment(
11066        &mut self,
11067        sub_index: usize,
11068        bytes: &[u8],
11069        first_fresh_page: bool,
11070        row_count: i64,
11071        as_of_commit_seq: i64,
11072        scope_digest: &str,
11073    ) -> Result<u32, SectionError> {
11074        let invalid = |detail: &str| {
11075            SectionError::Abort(
11076                "sync.invalid_request".to_owned(),
11077                format!("sqlite segment rejected: {detail} (§5.3)"),
11078            )
11079        };
11080        let (sub_table, effective) = {
11081            let sub = &self.subs[sub_index];
11082            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11083        };
11084        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11085            SectionError::Abort(
11086                "sync.schema_mismatch".to_owned(),
11087                format!("subscription table {sub_table:?} is not in the client schema"),
11088            )
11089        })?;
11090
11091        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
11092        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
11093        let img = match rusqlite::Connection::open_with_flags(
11094            &path,
11095            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
11096        ) {
11097            Ok(conn) => conn,
11098            Err(_) => {
11099                let _ = std::fs::remove_file(&path);
11100                return Err(invalid("bytes do not open as a SQLite database"));
11101            }
11102        };
11103        let outcome = self.apply_sqlite_image(
11104            &img,
11105            &table,
11106            first_fresh_page,
11107            &effective,
11108            row_count,
11109            as_of_commit_seq,
11110            scope_digest,
11111        );
11112        drop(img);
11113        let _ = std::fs::remove_file(&path);
11114        outcome
11115    }
11116
11117    #[allow(clippy::too_many_arguments)]
11118    fn apply_sqlite_image(
11119        &mut self,
11120        img: &rusqlite::Connection,
11121        table: &crate::schema::TableSchema,
11122        first_fresh_page: bool,
11123        effective: &[(String, Vec<String>)],
11124        row_count: i64,
11125        as_of_commit_seq: i64,
11126        scope_digest: &str,
11127    ) -> Result<u32, SectionError> {
11128        let invalid = |detail: String| {
11129            SectionError::Abort(
11130                "sync.invalid_request".to_owned(),
11131                format!("sqlite segment rejected: {detail} (§5.3)"),
11132            )
11133        };
11134
11135        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
11136        //    one row).
11137        type MetaRow = (i64, String, i64, i64, String, i64, i64);
11138        let meta: MetaRow = img
11139            .query_row(
11140                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
11141                        \"scopeDigest\", \"rowCount\",
11142                        (SELECT count(*) FROM _syncular_segment)
11143                 FROM _syncular_segment",
11144                [],
11145                |row| {
11146                    Ok((
11147                        row.get(0)?,
11148                        row.get(1)?,
11149                        row.get(2)?,
11150                        row.get(3)?,
11151                        row.get(4)?,
11152                        row.get(5)?,
11153                        row.get(6)?,
11154                    ))
11155                },
11156            )
11157            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
11158        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
11159        if meta_count != 1 {
11160            return Err(invalid(format!(
11161                "_syncular_segment must contain exactly one row, found {meta_count}"
11162            )));
11163        }
11164        if format != 1 {
11165            return Err(invalid(format!("format {format}")));
11166        }
11167        if meta_table != table.name {
11168            return Err(invalid(format!("image table {meta_table:?}")));
11169        }
11170        if schema_version != i64::from(self.schema.version) {
11171            return Err(invalid(format!("schemaVersion {schema_version}")));
11172        }
11173        if pin != as_of_commit_seq {
11174            return Err(invalid(format!("asOfCommitSeq {pin}")));
11175        }
11176        if digest != scope_digest {
11177            return Err(invalid("scopeDigest mismatch".to_owned()));
11178        }
11179        if meta_rows != row_count {
11180            return Err(invalid(format!("rowCount {meta_rows}")));
11181        }
11182
11183        // 2. Column names and order vs the generated schema (§5.3 rule 3).
11184        let mut names: Vec<String> = Vec::new();
11185        let mut primary_keys: Vec<String> = Vec::new();
11186        {
11187            let mut stmt = img
11188                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
11189                .map_err(|_| invalid("image data table missing".to_owned()))?;
11190            let mut rows = stmt
11191                .query([])
11192                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
11193            while let Some(row) = rows
11194                .next()
11195                .map_err(|_| invalid("image data table unreadable".to_owned()))?
11196            {
11197                if row
11198                    .get::<_, i64>(5)
11199                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
11200                    > 0
11201                {
11202                    primary_keys.push(row.get(1).map_err(|error| {
11203                        invalid(Self::sqlite_failure(&self.storage_failure, error))
11204                    })?);
11205                }
11206                names.push(
11207                    row.get::<_, String>(1)
11208                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
11209                );
11210            }
11211        }
11212        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
11213        expected.push("_syncular_version");
11214        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
11215            return Err(SectionError::Abort(
11216                "sync.schema_mismatch".to_owned(),
11217                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
11218            ));
11219        }
11220
11221        if primary_keys != vec![table.primary_key.clone()] {
11222            return Err(invalid(
11223                "image primary key does not match generated schema".into(),
11224            ));
11225        }
11226        let actual_count: i64 = img
11227            .query_row(
11228                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
11229                [],
11230                |row| row.get(0),
11231            )
11232            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11233        if actual_count != row_count {
11234            return Err(invalid("image row count does not match descriptor".into()));
11235        }
11236        let insert = self.insert_row_sql(&base_table(&table.name), table);
11237        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
11238        let column_list = names
11239            .iter()
11240            .map(|n| quote_ident(n))
11241            .collect::<Vec<_>>()
11242            .join(", ");
11243        let mut stmt = img
11244            .prepare(&format!(
11245                "SELECT {column_list} FROM {} ORDER BY {}",
11246                quote_ident(&table.name),
11247                quote_ident(&table.primary_key)
11248            ))
11249            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11250        let mut rows = stmt
11251            .query([])
11252            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11253        let mut applied = 0u32;
11254        loop {
11255            let clear = applied == 0 && first_fresh_page;
11256            let was_dirty = self.overlay_dirty.get();
11257            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
11258                && self
11259                    .outbox
11260                    .iter()
11261                    .flat_map(|commit| &commit.ops)
11262                    .any(|op| op.table == table.name);
11263            let mirror_visible = !reconcile_pending && self.failed_commits.is_empty();
11264            self.begin_observation("syncular_image_chunk")
11265                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11266            let result = (|| {
11267                self.rebuild_overlay_if_dirty();
11268                let mut changed_keys = Vec::new();
11269                let mut batch = ChangeAccumulator::default();
11270                if clear {
11271                    if self.scoped_rows_exist(&table.name, effective) {
11272                        batch.table(&table.name);
11273                    }
11274                    self.purge_scope_rows(&table.name, effective)
11275                        .map_err(|()| SectionError::FailClosed)?;
11276                }
11277                let mut processed = 0u32;
11278                {
11279                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
11280                        invalid(Self::sqlite_failure(&self.storage_failure, error))
11281                    })?;
11282                    let mut mirror = if mirror_visible {
11283                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
11284                            invalid(Self::sqlite_failure(&self.storage_failure, error))
11285                        })?)
11286                    } else {
11287                        None
11288                    };
11289                    for _ in 0..1024 {
11290                        let Some(row) = rows.next().map_err(|error| {
11291                            invalid(Self::sqlite_failure(&self.storage_failure, error))
11292                        })?
11293                        else {
11294                            break;
11295                        };
11296                        for (i, column) in table.columns.iter().enumerate() {
11297                            let cell = row.get_ref(i).map_err(|error| {
11298                                invalid(Self::sqlite_failure(&self.storage_failure, error))
11299                            })?;
11300                            let param = image_cell_param(column, cell).map_err(invalid)?;
11301                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
11302                                invalid(Self::sqlite_failure(&self.storage_failure, error))
11303                            })?;
11304                            if let Some(mirror) = &mut mirror {
11305                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
11306                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
11307                                })?;
11308                            }
11309                        }
11310                        let version_index = table.columns.len();
11311                        let version: i64 = row.get(version_index).map_err(|error| {
11312                            invalid(Self::sqlite_failure(&self.storage_failure, error))
11313                        })?;
11314                        if version < 1 {
11315                            return Err(invalid("image row version must be positive".into()));
11316                        }
11317                        ins.raw_bind_parameter(version_index + 1, version)
11318                            .map_err(|error| {
11319                                invalid(Self::sqlite_failure(&self.storage_failure, error))
11320                            })?;
11321                        ins.raw_execute().map_err(|error| {
11322                            invalid(Self::sqlite_failure(&self.storage_failure, error))
11323                        })?;
11324                        if let Some(mirror) = &mut mirror {
11325                            mirror
11326                                .raw_bind_parameter(version_index + 1, version)
11327                                .map_err(|error| {
11328                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
11329                                })?;
11330                            mirror.raw_execute().map_err(|error| {
11331                                invalid(Self::sqlite_failure(&self.storage_failure, error))
11332                            })?;
11333                        }
11334                        if reconcile_pending {
11335                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
11336                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
11337                            )?);
11338                        }
11339                        processed += 1;
11340                    }
11341                }
11342                if processed > 0 {
11343                    batch.table(&table.name);
11344                    self.overlay_dirty.set(true);
11345                }
11346                if reconcile_pending {
11347                    self.reconcile_imported_rows(table, &mut changed_keys)
11348                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11349                }
11350                if self.failed_commits.is_empty() {
11351                    self.apply_outbox_ops(
11352                        self.outbox
11353                            .iter()
11354                            .flat_map(|commit| &commit.ops)
11355                            .filter(|op| op.table == table.name),
11356                    );
11357                    self.overlay_dirty.set(false);
11358                } else {
11359                    self.rebuild_overlay();
11360                }
11361                self.finish_observation("syncular_image_chunk", batch)
11362                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11363                Ok(processed)
11364            })();
11365            let processed = match result {
11366                Ok(processed) => processed,
11367                Err(error) => {
11368                    self.rollback_observation("syncular_image_chunk");
11369                    self.overlay_dirty.set(was_dirty);
11370                    return Err(error);
11371                }
11372            };
11373            applied += processed;
11374            self.progress
11375                .update(|p| p.rows_processed = u64::from(applied));
11376            std::thread::yield_now();
11377            if i64::from(applied) == actual_count {
11378                break;
11379            }
11380        }
11381        Ok(applied)
11382    }
11383
11384    /// Restore only imported and pending row identities before FIFO replay. All pending
11385    /// identities participate: a server row can free a unique value for a different edit.
11386    /// The caller owns the chunk transaction and publishes its revision after replay.
11387    fn reconcile_imported_rows(
11388        &self,
11389        table: &TableSchema,
11390        keys: &mut Vec<SqlValue>,
11391    ) -> Result<(), String> {
11392        let visible = visible_table(&table.name);
11393        let base = base_table(&table.name);
11394        let pk = quote_ident(&table.primary_key);
11395        let predicate = row_id_predicate(table);
11396        let mut lookup = self.conn.prepare_cached(&format!(
11397            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
11398        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11399        let mut deleted = HashSet::new();
11400        for op in self
11401            .outbox
11402            .iter()
11403            .flat_map(|commit| &commit.ops)
11404            .filter(|op| op.table == table.name)
11405        {
11406            if op.upsert {
11407                if let Some(values) = &op.values {
11408                    let key = json_to_column_value(
11409                        &table.columns[table.pk_index],
11410                        values.get(&table.primary_key),
11411                    )?;
11412                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
11413                }
11414            } else if deleted.insert(&op.row_id) {
11415                keys.extend(
11416                    lookup
11417                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
11418                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
11419                        .collect::<Result<Vec<_>, _>>()
11420                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
11421                );
11422            }
11423        }
11424        // Delete the complete affected set before restoring any base value, so
11425        // old optimistic unique values cannot collide with the new server rows.
11426        for chunk in keys.chunks(400) {
11427            let holes = vec!["?"; chunk.len()].join(",");
11428            self.conn
11429                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
11430                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
11431                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11432        }
11433        let updates = table
11434            .columns
11435            .iter()
11436            .map(|column| column.name.as_str())
11437            .chain(std::iter::once("_syncular_version"))
11438            .filter(|name| *name != table.primary_key)
11439            .map(|name| {
11440                let name = quote_ident(name);
11441                format!("{name}=excluded.{name}")
11442            })
11443            .collect::<Vec<_>>()
11444            .join(",");
11445        for chunk in keys.chunks(400) {
11446            let holes = vec!["?"; chunk.len()].join(",");
11447            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
11448                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
11449                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11450        }
11451        Ok(())
11452    }
11453
11454    // -- application-authorized local purge ---------------------------------------
11455
11456    fn compile_local_data_purge(
11457        &self,
11458        input: &LocalDataPurgeInput,
11459    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
11460        let invalid = |message: String| format!("sync.invalid_request: {message}");
11461        if input.purge_id.is_empty()
11462            || input.purge_id.len() > 128
11463            || !is_local_operation_code_like(&input.purge_id)
11464        {
11465            return Err(invalid(
11466                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
11467            ));
11468        }
11469        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
11470            return Err(invalid(format!(
11471                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
11472            )));
11473        }
11474
11475        let mut deduplicated: BTreeMap<
11476            String,
11477            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
11478        > = BTreeMap::new();
11479        for target in &input.targets {
11480            let table = self.schema.table(&target.table).ok_or_else(|| {
11481                invalid(format!(
11482                    "local purge names unknown table {:?}",
11483                    target.table
11484                ))
11485            })?;
11486            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
11487                return Err(invalid(format!(
11488                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
11489                    target.table
11490                )));
11491            }
11492            let mut selectors = Vec::with_capacity(target.selectors.len());
11493            let mut canonical_selectors = BTreeMap::new();
11494            for (column_name, raw_values) in &target.selectors {
11495                let Some((column_index, column)) = table
11496                    .columns
11497                    .iter()
11498                    .enumerate()
11499                    .find(|(_, column)| column.name == *column_name)
11500                else {
11501                    return Err(invalid(format!(
11502                        "local purge target {:?} names unknown column {:?}",
11503                        target.table, column_name
11504                    )));
11505                };
11506                let encrypted = table
11507                    .encrypted_columns
11508                    .iter()
11509                    .any(|candidate| candidate.index == column_index);
11510                if column.ty != ColumnType::String || encrypted {
11511                    return Err(invalid(format!(
11512                        "local purge selector {:?}.{:?} must be a plaintext string column",
11513                        target.table, column_name
11514                    )));
11515                }
11516                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
11517                    return Err(invalid(format!(
11518                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
11519                        target.table, column_name
11520                    )));
11521                }
11522                let mut values = raw_values.clone();
11523                values.sort();
11524                values.dedup();
11525                if values.iter().any(|value| {
11526                    value.is_empty()
11527                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
11528                        || !is_local_operation_code_like(value)
11529                }) {
11530                    return Err(invalid(format!(
11531                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
11532                    )));
11533                }
11534                selectors.push((column_name.clone(), values.clone()));
11535                canonical_selectors.insert(column_name.clone(), values);
11536            }
11537            let canonical = LocalDataPurgeTarget {
11538                table: target.table.clone(),
11539                selectors: canonical_selectors,
11540            };
11541            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11542            deduplicated.insert(
11543                key,
11544                (
11545                    CompiledLocalDataPurgeTarget {
11546                        table: target.table.clone(),
11547                        selectors,
11548                    },
11549                    canonical,
11550                ),
11551            );
11552        }
11553        let targets = deduplicated
11554            .values()
11555            .map(|(compiled, _)| compiled.clone())
11556            .collect::<Vec<_>>();
11557        let canonical = deduplicated
11558            .values()
11559            .map(|(_, target)| target.clone())
11560            .collect::<Vec<_>>();
11561        let canonical_plan =
11562            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11563        Ok((targets, canonical_plan))
11564    }
11565
11566    fn local_purge_base_row_ids(
11567        &self,
11568        targets: &[CompiledLocalDataPurgeTarget],
11569    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
11570        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11571        for target in targets {
11572            let table = self
11573                .schema
11574                .table(&target.table)
11575                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
11576            let mut clauses = Vec::with_capacity(target.selectors.len());
11577            let mut params = Vec::new();
11578            for (column, values) in &target.selectors {
11579                clauses.push(format!(
11580                    "{} IN ({})",
11581                    quote_ident(column),
11582                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
11583                ));
11584                params.extend(values.iter().cloned().map(SqlValue::Text));
11585            }
11586            let sql = format!(
11587                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
11588                quote_ident(&table.primary_key),
11589                base_table(&target.table),
11590                clauses.join(" AND ")
11591            );
11592            let mut statement = self
11593                .conn
11594                .prepare(&sql)
11595                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11596            let rows = statement
11597                .query_map(rusqlite::params_from_iter(params), |row| {
11598                    row.get::<_, String>(0)
11599                })
11600                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11601            let ids = by_table.entry(target.table.clone()).or_default();
11602            for row in rows {
11603                ids.insert(
11604                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
11605                );
11606            }
11607        }
11608        Ok(by_table)
11609    }
11610
11611    fn local_purge_values_match(
11612        target: &CompiledLocalDataPurgeTarget,
11613        values: &Map<String, Value>,
11614    ) -> bool {
11615        target.selectors.iter().all(|(column, allowed)| {
11616            values
11617                .get(column)
11618                .and_then(Value::as_str)
11619                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
11620        })
11621    }
11622
11623    /// Apply one host-authorized local security purge. The host MUST gate the
11624    /// corresponding subscriptions first; this primitive owns local SQLite
11625    /// cleanup and never grants/revokes server authority by itself.
11626    pub fn purge_local_data(
11627        &mut self,
11628        input: &LocalDataPurgeInput,
11629    ) -> Result<LocalDataPurgeResult, String> {
11630        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
11631        let meta_key = format!("localPurge:{}", input.purge_id);
11632        let applied_plan = self
11633            .conn
11634            .query_row(
11635                "SELECT value FROM _syncular_meta WHERE key = ?1",
11636                rusqlite::params![meta_key],
11637                |row| row.get::<_, String>(0),
11638            )
11639            .optional()
11640            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11641        if let Some(applied_plan) = applied_plan {
11642            if applied_plan != canonical_plan {
11643                return Err(format!(
11644                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
11645                    input.purge_id
11646                ));
11647            }
11648            return Ok(LocalDataPurgeResult {
11649                already_applied: true,
11650                purged_rows: 0,
11651                dropped_commits: 0,
11652            });
11653        }
11654
11655        // RFC 0005 D7: the container file cannot be a purge target (its name is
11656        // not in the running schema), so purge drops it as a fixed step, before
11657        // any mirror row is touched and unconditionally.
11658        let _ = self.drop_previous_version();
11659
11660        let prior_outbox = self.outbox.clone();
11661        let prior_failed = self.failed_commits.clone();
11662        let prior_rejection_count = self.rejections.len();
11663        let prior_overlay_dirty = self.overlay_dirty.get();
11664        self.begin_observation("syncular_local_purge")?;
11665        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
11666            let row_ids = self.local_purge_base_row_ids(&targets)?;
11667            let retained_dropped = self.drop_failed_commits(|table, values| {
11668                targets.iter().any(|target| {
11669                    target.table == table && Self::local_purge_values_match(target, values)
11670                })
11671            })?;
11672            let doomed = self
11673                .outbox
11674                .iter()
11675                .filter(|commit| {
11676                    commit.ops.iter().any(|operation| {
11677                        let matching_targets = targets
11678                            .iter()
11679                            .filter(|target| target.table == operation.table)
11680                            .collect::<Vec<_>>();
11681                        if matching_targets.is_empty() {
11682                            return false;
11683                        }
11684                        if row_ids
11685                            .get(&operation.table)
11686                            .is_some_and(|ids| ids.contains(&operation.row_id))
11687                        {
11688                            return true;
11689                        }
11690                        operation.values.as_ref().is_some_and(|values| {
11691                            matching_targets
11692                                .iter()
11693                                .any(|target| Self::local_purge_values_match(target, values))
11694                        })
11695                    })
11696                })
11697                .cloned()
11698                .collect::<Vec<_>>();
11699            let doomed_ids = doomed
11700                .iter()
11701                .map(|commit| commit.client_commit_id.clone())
11702                .collect::<BTreeSet<_>>();
11703            let mut batch = ChangeAccumulator::default();
11704            for commit in &retained_dropped {
11705                batch.outcomes = true;
11706                for operation in &commit.ops {
11707                    batch.table(&operation.table);
11708                }
11709            }
11710            let mut rejections = Vec::new();
11711            for commit in &doomed {
11712                for operation in &commit.ops {
11713                    batch.table(&operation.table);
11714                }
11715                let results = commit
11716                    .ops
11717                    .iter()
11718                    .enumerate()
11719                    .map(|(op_index, operation)| {
11720                        let rejection = RejectionRecord {
11721                            client_commit_id: commit.client_commit_id.clone(),
11722                            op_index: op_index as i32,
11723                            code: "client.local_data_purged".to_owned(),
11724                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
11725                            retryable: false,
11726                            details: None,
11727                            operation: Some(CommitOperation::from(operation)),
11728                        };
11729                        rejections.push(rejection.clone());
11730                        CommitOperationOutcome::Error { rejection }
11731                    })
11732                    .collect::<Vec<_>>();
11733                self.persist_commit_outcome(
11734                    &commit.client_commit_id,
11735                    CommitOutcomeStatus::Rejected,
11736                    &results,
11737                    Some(&commit.ops),
11738                )?;
11739                self.delete_outbox_persisted(&commit.client_commit_id)?;
11740            }
11741            if !doomed.is_empty() {
11742                self.outbox
11743                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
11744                self.rejections.extend(rejections);
11745                self.prune_commit_outcomes()?;
11746                self.overlay_dirty.set(true);
11747                batch.status = true;
11748                batch.rejections = true;
11749                batch.outcomes = true;
11750            }
11751
11752            let mut purged_rows = 0usize;
11753            for (table, ids) in &row_ids {
11754                if ids.is_empty() {
11755                    continue;
11756                }
11757                batch.table(table);
11758                for row_id in ids {
11759                    self.delete_base_row(table, row_id)?;
11760                    purged_rows += 1;
11761                }
11762            }
11763            self.rebuild_overlay_if_dirty();
11764            self.delete_unreferenced_cached_blobs();
11765            self.conn
11766                .execute(
11767                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11768                    rusqlite::params![meta_key, canonical_plan],
11769                )
11770                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11771            Ok((
11772                batch,
11773                LocalDataPurgeResult {
11774                    already_applied: false,
11775                    purged_rows,
11776                    dropped_commits: doomed.len(),
11777                },
11778            ))
11779        })();
11780        let (batch, result) = match applied {
11781            Ok(value) => value,
11782            Err(error) => {
11783                self.rollback_observation("syncular_local_purge");
11784                self.outbox = prior_outbox;
11785                self.failed_commits = prior_failed;
11786                self.rejections.truncate(prior_rejection_count);
11787                self.overlay_dirty.set(prior_overlay_dirty);
11788                return Err(error);
11789            }
11790        };
11791        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
11792            self.rollback_observation("syncular_local_purge");
11793            self.outbox = prior_outbox;
11794            self.failed_commits = prior_failed;
11795            self.rejections.truncate(prior_rejection_count);
11796            self.overlay_dirty.set(prior_overlay_dirty);
11797            return Err(error);
11798        }
11799        Ok(result)
11800    }
11801
11802    /// Application-authorized recovery of the replicated projection. Unlike a
11803    /// local security purge, this retains the entire outbox, device identity,
11804    /// lease, outcomes, subscription registrations, and protected bookkeeping.
11805    /// The projection reset, subscription rewind, optimistic replay, and
11806    /// idempotency marker share one savepoint for interruption safety.
11807    pub fn rebootstrap_local_data(
11808        &mut self,
11809        input: &LocalDataRebootstrapInput,
11810    ) -> Result<LocalDataRebootstrapResult, String> {
11811        if input.rebootstrap_id.is_empty()
11812            || input.rebootstrap_id.len() > 128
11813            || !is_local_operation_code_like(&input.rebootstrap_id)
11814        {
11815            return Err(
11816                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
11817                    .to_owned(),
11818            );
11819        }
11820        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
11821        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
11822            let (retained_commits, reset_subscriptions) =
11823                decode_local_rebootstrap_receipt(&persisted)?;
11824            return Ok(LocalDataRebootstrapResult {
11825                already_applied: true,
11826                retained_commits,
11827                reset_subscriptions,
11828            });
11829        }
11830        if self.stopped || self.schema_floor.is_some() {
11831            return Err(
11832                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
11833                    .to_owned(),
11834            );
11835        }
11836
11837        let retained_commits = self.outbox.len();
11838        let reset_subscriptions = self.subs.len();
11839        let prior_subs = self.subs.clone();
11840        let prior_upgrading = self.upgrading;
11841        let prior_stopped = self.stopped;
11842        let prior_schema_floor = self.schema_floor.clone();
11843        let prior_overlay_dirty = self.overlay_dirty.get();
11844        let prior_sync_needed = self.sync_needed;
11845        let prior_sync_intents = self.sync_intent_queue.clone();
11846        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
11847
11848        self.begin_observation("syncular_local_rebootstrap")?;
11849        let mut batch = ChangeAccumulator::default();
11850        let applied = (|| -> Result<(), String> {
11851            self.run_schema_reset_observed(&mut batch, false, false)?;
11852            self.conn
11853                .execute(
11854                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11855                    rusqlite::params![meta_key, receipt],
11856                )
11857                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11858            self.sync_needed = true;
11859            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11860            batch.status = true;
11861            Ok(())
11862        })();
11863        if let Err(error) = applied {
11864            self.rollback_observation("syncular_local_rebootstrap");
11865            self.subs = prior_subs;
11866            self.upgrading = prior_upgrading;
11867            self.stopped = prior_stopped;
11868            self.schema_floor = prior_schema_floor;
11869            self.overlay_dirty.set(prior_overlay_dirty);
11870            self.sync_needed = prior_sync_needed;
11871            self.sync_intent_queue = prior_sync_intents;
11872            return Err(error);
11873        }
11874        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
11875            self.rollback_observation("syncular_local_rebootstrap");
11876            self.subs = prior_subs;
11877            self.upgrading = prior_upgrading;
11878            self.stopped = prior_stopped;
11879            self.schema_floor = prior_schema_floor;
11880            self.overlay_dirty.set(prior_overlay_dirty);
11881            self.sync_needed = prior_sync_needed;
11882            self.sync_intent_queue = prior_sync_intents;
11883            return Err(error);
11884        }
11885
11886        Ok(LocalDataRebootstrapResult {
11887            already_applied: false,
11888            retained_commits,
11889            reset_subscriptions,
11890        })
11891    }
11892
11893    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
11894
11895    /// Delete base rows matching the effective scopes; `Err(())` = no local
11896    /// scope-column mapping for a key (the fail-closed case).
11897    fn purge_scope_rows(
11898        &mut self,
11899        table_name: &str,
11900        effective: &[(String, Vec<String>)],
11901    ) -> Result<(), ()> {
11902        if effective.is_empty() {
11903            return Ok(());
11904        }
11905        let table = self.schema.table(table_name).ok_or(())?.clone();
11906        let mut clauses = Vec::new();
11907        let mut params: Vec<SqlValue> = Vec::new();
11908        for (variable, values) in effective {
11909            let column = table.scope_column(variable).ok_or(())?;
11910            let placeholders: Vec<String> = values
11911                .iter()
11912                .map(|v| {
11913                    params.push(SqlValue::Text(v.clone()));
11914                    "?".to_owned()
11915                })
11916                .collect();
11917            clauses.push(format!(
11918                "{} IN ({})",
11919                quote_ident(column),
11920                placeholders.join(", ")
11921            ));
11922        }
11923        let sql = format!(
11924            "DELETE FROM {} WHERE {}",
11925            base_table(table_name),
11926            clauses.join(" AND ")
11927        );
11928        self.overlay_dirty.set(true);
11929        self.conn
11930            .execute(&sql, rusqlite::params_from_iter(&params))
11931            .map_err(|_| ())?;
11932        self.conn
11933            .execute(
11934                &format!(
11935                    "DELETE FROM {} WHERE {}",
11936                    visible_table(table_name),
11937                    clauses.join(" AND ")
11938                ),
11939                rusqlite::params_from_iter(&params),
11940            )
11941            .map_err(|_| ())?;
11942        Ok(())
11943    }
11944
11945    /// §3.3: drop pending commits whose upserts provably land in the
11946    /// revoked effective scopes — whole-commit, never per-operation.
11947    fn drop_doomed_outbox(
11948        &mut self,
11949        table_name: &str,
11950        effective: &[(String, Vec<String>)],
11951    ) -> Result<bool, String> {
11952        if effective.is_empty() {
11953            return Ok(false);
11954        }
11955        let Some(table) = self.schema.table(table_name).cloned() else {
11956            return Ok(false);
11957        };
11958        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
11959        for (variable, values) in effective {
11960            match table.scope_column(variable) {
11961                Some(column) => mappings.push((column, values)),
11962                None => return Ok(false), // not provable without a mapping
11963            }
11964        }
11965        let doomed: Vec<OutboxCommit> = self
11966            .outbox
11967            .iter()
11968            .filter(|commit| {
11969                commit.ops.iter().any(|op| {
11970                    op.upsert
11971                        && op.table == table_name
11972                        && op.values.as_ref().is_some_and(|values| {
11973                            mappings
11974                                .iter()
11975                                .all(|(column, allowed)| match values.get(*column) {
11976                                    Some(Value::String(s)) => allowed.contains(s),
11977                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
11978                                    _ => false,
11979                                })
11980                        })
11981                })
11982            })
11983            .cloned()
11984            .collect();
11985        if doomed.is_empty() {
11986            return Ok(false);
11987        }
11988        let mut rejections = Vec::new();
11989        for commit in &doomed {
11990            let results = commit
11991                .ops
11992                .iter()
11993                .enumerate()
11994                .map(|(op_index, operation)| {
11995                    let rejection = RejectionRecord {
11996                        client_commit_id: commit.client_commit_id.clone(),
11997                        op_index: op_index as i32,
11998                        code: "sync.scope_revoked".to_owned(),
11999                        message: "the commit was dropped because its effective scope was revoked"
12000                            .to_owned(),
12001                        retryable: false,
12002                        details: None,
12003                        operation: Some(CommitOperation::from(operation)),
12004                    };
12005                    rejections.push(rejection.clone());
12006                    CommitOperationOutcome::Error { rejection }
12007                })
12008                .collect::<Vec<_>>();
12009            self.persist_commit_outcome(
12010                &commit.client_commit_id,
12011                CommitOutcomeStatus::Rejected,
12012                &results,
12013                Some(&commit.ops),
12014            )?;
12015            self.delete_outbox_persisted(&commit.client_commit_id)?;
12016        }
12017        self.prune_commit_outcomes()?;
12018        let doomed_ids = doomed
12019            .iter()
12020            .map(|commit| commit.client_commit_id.as_str())
12021            .collect::<BTreeSet<_>>();
12022        self.outbox
12023            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
12024        self.rejections.extend(rejections);
12025        self.overlay_dirty.set(true);
12026        Ok(true)
12027    }
12028
12029    // -- blobs (§5.9) ----------------------------------------------------------------
12030
12031    /// §5.9.7: hash bytes into the content address, cache them, queue the
12032    /// upload (flushed before the next push, B4). Returns the canonical
12033    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
12034    /// column value.
12035    pub fn upload_blob(
12036        &mut self,
12037        bytes: &[u8],
12038        media_type: Option<String>,
12039        name: Option<String>,
12040    ) -> Result<Value, String> {
12041        let blob_id = blob_id_for(bytes);
12042        let now = self.clock_now_ms();
12043        let tx = self
12044            .conn
12045            .transaction()
12046            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12047        tx.execute(
12048            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
12049             ON CONFLICT(blob_id) DO NOTHING",
12050            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
12051        )
12052        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12053        tx.execute(
12054            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
12055            rusqlite::params![blob_id, media_type, now],
12056        )
12057        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12058        tx.commit()
12059            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12060        self.enforce_blob_cache_cap();
12061        let mut obj = Map::new();
12062        obj.insert("blobId".to_owned(), Value::from(blob_id));
12063        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
12064        if let Some(mt) = media_type {
12065            obj.insert("mediaType".to_owned(), Value::from(mt));
12066        }
12067        if let Some(n) = name {
12068            obj.insert("name".to_owned(), Value::from(n));
12069        }
12070        Ok(Value::Object(obj))
12071    }
12072
12073    /// §5.9.7: resolve an owned blob body without driver encoding. A
12074    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
12075    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
12076    pub fn fetch_blob_bytes(
12077        &mut self,
12078        transport: &mut dyn Transport,
12079        blob_id_or_ref: &str,
12080    ) -> Result<FetchedBlob, (String, String)> {
12081        let simple = |m: String| ("client.failed".to_owned(), m);
12082        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
12083            blob_id_or_ref.to_owned()
12084        } else {
12085            let value: Value = serde_json::from_str(blob_id_or_ref)
12086                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
12087            value
12088                .get("blobId")
12089                .and_then(Value::as_str)
12090                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
12091                .to_owned()
12092        };
12093        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
12094            return Ok(cached);
12095        }
12096        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
12097        // blob.not_found) verbatim so the harness can assert on it. The
12098        // authorized endpoint serves bytes inline OR (always-issue, presign
12099        // configured) a signed url the client fetches directly — no host auth,
12100        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
12101        #[cfg(feature = "bench-internals")]
12102        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
12103        let bytes = match transport
12104            .blob_download(&blob_id)
12105            .map_err(|e| (e.code, e.message))?
12106        {
12107            BlobDownload::Bytes(bytes) => bytes,
12108            BlobDownload::Url {
12109                url,
12110                url_expires_at_ms,
12111            } => {
12112                // §5.9.5: MUST NOT start a fetch at/past expiry.
12113                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
12114                    return Err((
12115                        "sync.segment_expired".to_owned(),
12116                        format!(
12117                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
12118                        ),
12119                    ));
12120                }
12121                transport
12122                    .fetch_blob_url(&url)
12123                    .map_err(|e| (e.code, e.message))?
12124            }
12125        };
12126        #[cfg(feature = "bench-internals")]
12127        drop(download_phase);
12128        #[cfg(feature = "bench-internals")]
12129        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
12130        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
12131        if blob_id_for(&bytes) != blob_id {
12132            return Err(simple(format!(
12133                "blob content address mismatch for {blob_id}"
12134            )));
12135        }
12136        #[cfg(feature = "bench-internals")]
12137        drop(validate_phase);
12138        #[cfg(feature = "bench-internals")]
12139        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
12140        let now = self.clock_now_ms();
12141        self.conn
12142            .execute(
12143                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
12144                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
12145            )
12146            .map_err(|e| simple(e.to_string()))?;
12147        self.conn
12148            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
12149            .map_err(|e| simple(e.to_string()))?;
12150        self.enforce_blob_cache_cap();
12151        #[cfg(feature = "bench-internals")]
12152        drop(insert_phase);
12153        self.get_cached_blob_bytes(&blob_id)
12154            .map_err(simple)?
12155            .ok_or_else(|| simple("blob cache write failed".to_owned()))
12156    }
12157
12158    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
12159        #[cfg(feature = "bench-internals")]
12160        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
12161        let mut stmt = self
12162            .conn
12163            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
12164            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12165        let mut rows = stmt
12166            .query(rusqlite::params![blob_id])
12167            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12168        if let Some(row) = rows
12169            .next()
12170            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12171        {
12172            let bytes: Vec<u8> = row
12173                .get(0)
12174                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12175            let byte_length: i64 = row
12176                .get(1)
12177                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12178            let media_type: Option<String> = row
12179                .get(2)
12180                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12181            return Ok(Some(FetchedBlob {
12182                blob_id: blob_id.to_owned(),
12183                byte_length,
12184                bytes,
12185                media_type,
12186            }));
12187        }
12188        Ok(None)
12189    }
12190
12191    /// §5.9.7 B4: upload every queued blob before push.
12192    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
12193        let pending: Vec<(String, Option<String>)> = {
12194            let mut stmt = self
12195                .conn
12196                .prepare(
12197                    "SELECT blob_id, media_type FROM (
12198                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
12199                      UNION ALL
12200                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
12201                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
12202                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
12203                    ) ORDER BY created_at_ms, blob_id",
12204                )
12205                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12206            let rows = stmt
12207                .query_map([], |row| {
12208                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
12209                })
12210                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12211            rows.collect::<Result<_, _>>()
12212                .map_err(|error| match error {
12213                    rusqlite::Error::InvalidColumnType(..)
12214                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
12215                        "sync.local_corrupt",
12216                        "Pending blob upload metadata is invalid",
12217                    ),
12218                    _ => TransportError::new("client.failed", error.to_string()),
12219                })?
12220        };
12221        for (blob_id, media_type) in pending {
12222            let stored: Option<(SqlValue, SqlValue)> = self
12223                .conn
12224                .query_row(
12225                    "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
12226                    rusqlite::params![blob_id],
12227                    |row| Ok((row.get(0)?, row.get(1)?)),
12228                )
12229                .optional()
12230                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12231            let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
12232                return Err(TransportError::new(
12233                    "sync.local_corrupt",
12234                    "Pending blob upload body is missing or corrupt",
12235                ));
12236            };
12237            if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
12238                return Err(TransportError::new(
12239                    "sync.local_corrupt",
12240                    "Pending blob upload body is missing or corrupt",
12241                ));
12242            }
12243            self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
12244            self.conn
12245                .execute(
12246                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
12247                    rusqlite::params![blob_id],
12248                )
12249                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12250        }
12251        Ok(())
12252    }
12253
12254    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
12255    /// grant when the transport supports it, else streaming through the direct
12256    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
12257    /// PUTs direct with no host auth; on a grant PUT failure the client streams
12258    /// through the direct endpoint — a *different, host-authenticated
12259    /// capability*, not a fall-through of the grant's authority.
12260    fn upload_one(
12261        &self,
12262        transport: &mut dyn Transport,
12263        blob_id: &str,
12264        bytes: &[u8],
12265        media_type: Option<&str>,
12266    ) -> Result<(), TransportError> {
12267        match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
12268            BlobUploadGrant::Present => return Ok(()), // idempotent §5.9.3
12269            BlobUploadGrant::Url {
12270                url,
12271                url_expires_at_ms,
12272            } => {
12273                let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
12274                if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
12275                    return Ok(());
12276                }
12277                // Failed/expired grant PUT — stream through the direct endpoint.
12278            }
12279            BlobUploadGrant::None => {
12280                // No presign store — stream through the direct endpoint.
12281            }
12282        }
12283        transport.blob_upload(blob_id, bytes, media_type)
12284    }
12285
12286    fn visible_blob_ids_sql(&self) -> String {
12287        let mut selects = Vec::new();
12288        for table in &self.schema.tables {
12289            for column in table
12290                .columns
12291                .iter()
12292                .filter(|column| column.ty == ColumnType::BlobRef)
12293            {
12294                let identifier = quote_ident(&column.name);
12295                selects.push(format!(
12296                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
12297                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
12298                     AND json_type({identifier}, '$.blobId') = 'text'",
12299                    quote_ident(&table.name),
12300                ));
12301            }
12302        }
12303        if selects.is_empty() {
12304            "SELECT NULL AS blob_id WHERE 0".to_owned()
12305        } else {
12306            selects.join(" UNION ")
12307        }
12308    }
12309
12310    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
12311    /// staged uploads pin bodies directly; no derived refcount is stored.
12312    fn enforce_blob_cache_cap(&self) {
12313        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
12314            return;
12315        };
12316        let mut total: i64 = self
12317            .conn
12318            .query_row(
12319                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
12320                [],
12321                |row| row.get(0),
12322            )
12323            .unwrap_or(0);
12324        if total <= max_bytes {
12325            return;
12326        }
12327        let query = format!(
12328            "SELECT blob_id, byte_length FROM _syncular_blobs
12329             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
12330               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
12331               AND blob_id NOT IN ({})
12332             ORDER BY created_at_ms ASC, blob_id ASC",
12333            self.visible_blob_ids_sql(),
12334        );
12335        let candidates: Vec<(String, i64)> = {
12336            let Ok(mut stmt) = self.conn.prepare(&query) else {
12337                return;
12338            };
12339            let Ok(rows) = stmt.query_map([], |row| {
12340                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
12341            }) else {
12342                return;
12343            };
12344            rows.filter_map(Result::ok).collect()
12345        };
12346        for (blob_id, byte_length) in candidates {
12347            if total <= max_bytes {
12348                break;
12349            }
12350            let _ = self.conn.execute(
12351                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
12352                rusqlite::params![blob_id],
12353            );
12354            total -= byte_length;
12355        }
12356    }
12357
12358    fn delete_unreferenced_cached_blobs(&mut self) {
12359        #[cfg(feature = "bench-internals")]
12360        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
12361        if !self.schema_has_blobs() {
12362            return;
12363        }
12364        self.rebuild_overlay_if_dirty();
12365        let _ = self.conn.execute(
12366            &format!(
12367                "DELETE FROM _syncular_blobs
12368                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
12369                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
12370                   AND blob_id NOT IN ({})",
12371                self.visible_blob_ids_sql(),
12372            ),
12373            [],
12374        );
12375    }
12376
12377    // -- local row storage ----------------------------------------------------------
12378
12379    /// The cached per-table primary-key upsert SQL for `full_table` (see
12380    /// the `insert_sql` field: built once, reused per row).
12381    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
12382        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
12383            return sql.clone();
12384        }
12385        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
12386        columns.push(quote_ident("_syncular_version"));
12387        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
12388        let primary_key = quote_ident(&table.primary_key);
12389        let updates = columns
12390            .iter()
12391            .filter(|column| **column != primary_key)
12392            .map(|column| format!("{column}=excluded.{column}"))
12393            .collect::<Vec<_>>()
12394            .join(", ");
12395        let sql = format!(
12396            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
12397            columns.join(", "),
12398            placeholders.join(", ")
12399        );
12400        self.insert_sql
12401            .borrow_mut()
12402            .insert(full_table.to_owned(), sql.clone());
12403        sql
12404    }
12405
12406    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
12407        self.overlay_dirty.set(true);
12408        self.write_row(&base_table(table_name), table_name, row, version)
12409    }
12410
12411    fn write_row(
12412        &self,
12413        full_table: &str,
12414        table_name: &str,
12415        row: &Row,
12416        version: i64,
12417    ) -> Result<(), String> {
12418        #[cfg(feature = "bench-internals")]
12419        let _phase = self.benchmark_phases.start(Phase::RowWrite);
12420        let table = self
12421            .schema
12422            .table(table_name)
12423            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
12424        let sql = self.insert_row_sql(full_table, table);
12425        let mut stmt = self
12426            .conn
12427            .prepare_cached(&sql)
12428            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12429        let params = row
12430            .iter()
12431            .map(RowParam::Cell)
12432            .chain(std::iter::once(RowParam::Version(version)));
12433        stmt.execute(rusqlite::params_from_iter(params))
12434            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12435        Ok(())
12436    }
12437
12438    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
12439        let table = self
12440            .schema
12441            .table(table_name)
12442            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
12443        self.overlay_dirty.set(true);
12444        let sql = format!(
12445            "DELETE FROM {} WHERE {}",
12446            base_table(table_name),
12447            row_id_predicate(table)
12448        );
12449        let mut stmt = self
12450            .conn
12451            .prepare_cached(&sql)
12452            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12453        stmt.execute(rusqlite::params![row_id])
12454            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12455        Ok(())
12456    }
12457
12458    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
12459    /// the outbox changed since the last rebuild (the no-op sync round).
12460    fn rebuild_overlay_if_dirty(&mut self) {
12461        if self.overlay_dirty.get() {
12462            self.rebuild_overlay();
12463        }
12464    }
12465
12466    /// §7.1: local reads see outbox state applied optimistically — rebuild
12467    /// every visible table as (base server state) + (pending outbox replay
12468    /// on top). Optimistic rows carry version `-1`.
12469    fn rebuild_overlay(&mut self) {
12470        #[cfg(feature = "bench-internals")]
12471        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
12472        #[cfg(test)]
12473        self.overlay_rebuild_count
12474            .set(self.overlay_rebuild_count.get() + 1);
12475        self.exec("SAVEPOINT syncular_overlay");
12476        for table in self.schema.tables.clone() {
12477            for index in &table.fts_indexes {
12478                let _ = self.drop_fts_triggers(index);
12479            }
12480            let visible = visible_table(&table.name);
12481            let base = base_table(&table.name);
12482            self.exec(&format!("DELETE FROM {visible}"));
12483            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
12484        }
12485        for (commit, initial) in &self.failed_commits {
12486            for operation in &commit.ops {
12487                if !operation.upsert {
12488                    continue;
12489                }
12490                if let Some(table) = self.schema.table(&operation.table) {
12491                    if self.visible_row(table, &operation.row_id).is_none() {
12492                        let key = serde_json::to_string(&(
12493                            operation.table.clone(),
12494                            operation.row_id.clone(),
12495                        ))
12496                        .expect("row key serialization");
12497                        if let Some(Value::Object(values)) = initial.get(&key) {
12498                            self.apply_outbox_ops(&[OutboxOp {
12499                                values: Some(values.clone()),
12500                                ..operation.clone()
12501                            }]);
12502                        }
12503                    }
12504                }
12505            }
12506            self.apply_outbox_ops(&commit.ops);
12507        }
12508        for commit in &self.outbox {
12509            self.apply_outbox_ops(&commit.ops);
12510        }
12511        for table in self.schema.tables.clone() {
12512            for index in &table.fts_indexes {
12513                let _ = self.rebuild_fts_projection(&table, index);
12514                let _ = self.create_fts_triggers(&table, index);
12515            }
12516        }
12517        self.exec("RELEASE syncular_overlay");
12518        self.overlay_dirty.set(false);
12519    }
12520
12521    /// §7.1 replay: every operation is applied over the current visible
12522    /// state. An operation that no longer applies (a server row now holds its
12523    /// unique value) stays pending and invisible until a later replay admits
12524    /// it or the server answers its push.
12525    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
12526        #[cfg(feature = "bench-internals")]
12527        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
12528        for op in ops {
12529            #[cfg(feature = "bench-internals")]
12530            if let Some(phase) = &mut phase {
12531                phase.unit();
12532            }
12533            let _ = self.apply_outbox_op(op);
12534        }
12535    }
12536
12537    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
12538        let Some(table) = self.schema.table(&op.table) else {
12539            return Ok(());
12540        };
12541        let visible = visible_table(&table.name);
12542        if !op.upsert {
12543            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
12544            self.conn
12545                .prepare_cached(&sql)
12546                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
12547                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12548            return Ok(());
12549        }
12550        let Some(values) = op.values.as_ref() else {
12551            return Ok(());
12552        };
12553        // §7.1: the overlay applies the operation's PRESENT columns
12554        // over the current local row. A partial operation over an
12555        // absent local row leaves it absent — the server answers it
12556        // with sync.row_deleted or sync.row_missing (§6.2).
12557        let full = table
12558            .columns
12559            .iter()
12560            .all(|column| values.contains_key(&column.name));
12561        let local = self.visible_row(table, &op.row_id);
12562        if local.is_none() && !full {
12563            return Ok(());
12564        }
12565        let mut row: Row = Vec::with_capacity(table.columns.len());
12566        for (index, column) in table.columns.iter().enumerate() {
12567            let incoming = match values.get(&column.name) {
12568                Some(raw) => json_to_column_value(column, Some(raw)),
12569                None => Ok(local
12570                    .as_ref()
12571                    .and_then(|(values, _)| values.get(index).cloned())
12572                    .flatten()),
12573            };
12574            match incoming {
12575                Ok(value) => row.push(value),
12576                Err(_) => return Ok(()),
12577            }
12578        }
12579        let version = local.as_ref().map_or(-1, |(_, version)| *version);
12580        self.write_row(&visible, &table.name, &row, version)
12581            .map_err(|error| {
12582                if error.starts_with("UNIQUE constraint failed:") {
12583                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
12584                } else {
12585                    error
12586                }
12587            })
12588    }
12589
12590    /// The visible (optimistic) row's values plus version, or `None` when the
12591    /// row is absent from the overlay.
12592    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
12593        self.stored_row(table, row_id, false)
12594    }
12595
12596    fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
12597        let sql = format!(
12598            "SELECT * FROM {} WHERE {}",
12599            if base {
12600                base_table(&table.name)
12601            } else {
12602                visible_table(&table.name)
12603            },
12604            row_id_predicate(table)
12605        );
12606        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
12607        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
12608        let row = rows.next().ok()??;
12609        let mut values = Vec::with_capacity(table.columns.len());
12610        for (index, column) in table.columns.iter().enumerate() {
12611            let raw = row.get_ref(index).ok()?;
12612            let json = sql_ref_to_json(column, raw);
12613            values.push(json_to_column_value(column, Some(&json)).ok()?);
12614        }
12615        let version: i64 = row.get(table.columns.len()).ok()?;
12616        Some((values, version))
12617    }
12618
12619    fn exec(&self, sql: &str) {
12620        let _ = self.conn.execute_batch(sql);
12621    }
12622
12623    // -- realtime (§8) ---------------------------------------------------------------
12624
12625    /// §8.8: the configured realtime policy.
12626    #[must_use]
12627    pub fn realtime_policy(&self) -> RealtimePolicy {
12628        self.realtime_policy
12629    }
12630
12631    /// §8.8: the explicit realtime availability state.
12632    #[must_use]
12633    pub fn realtime_state(&self) -> RealtimeState {
12634        self.realtime_state
12635    }
12636
12637    /// §8.8: set the realtime policy. Refused while the socket is connected:
12638    /// a policy change never silently re-routes an ownership state.
12639    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
12640        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
12641            return Err(
12642                "sync.invalid_request: disconnect realtime before changing the realtime policy"
12643                    .to_owned(),
12644            );
12645        }
12646        self.realtime_policy = policy;
12647        match policy {
12648            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
12649            RealtimePolicy::Required | RealtimePolicy::Optional
12650                if self.realtime_state == RealtimeState::Disabled =>
12651            {
12652                self.set_realtime_state(RealtimeState::Disconnected, None);
12653            }
12654            RealtimePolicy::Required | RealtimePolicy::Optional => {}
12655        }
12656        Ok(())
12657    }
12658
12659    /// Record an availability transition, keeping the reason code bounded and
12660    /// code-like (§7.6) and clearing it where it cannot apply.
12661    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
12662        self.realtime_state = state;
12663        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
12664        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
12665            self.realtime_reason_code = None;
12666            self.realtime_retry_delay_ms = None;
12667        }
12668    }
12669
12670    #[must_use]
12671    fn realtime_connected(&self) -> bool {
12672        self.realtime_state == RealtimeState::Connected
12673    }
12674
12675    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
12676        if self.realtime_policy == RealtimePolicy::Off {
12677            return Err(
12678                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
12679            );
12680        }
12681        if self.realtime_connected() {
12682            return Ok(());
12683        }
12684        self.realtime_state = RealtimeState::Connecting;
12685        match transport.realtime_connect_for_client(&self.client_id) {
12686            Ok(()) => {
12687                self.set_realtime_state(RealtimeState::Connected, None);
12688                Ok(())
12689            }
12690            Err(error) => {
12691                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
12692                Err(format!("{}: {}", error.code, error.message))
12693            }
12694        }
12695    }
12696
12697    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
12698        // A deliberately disconnected or policy-disabled client has nothing
12699        // to release; a `lost` socket still does (the host owns the handle).
12700        if self.realtime_state == RealtimeState::Disconnected
12701            || self.realtime_state == RealtimeState::Disabled
12702        {
12703            return;
12704        }
12705        let _ = transport.realtime_close();
12706        self.set_realtime_state(
12707            if self.realtime_policy == RealtimePolicy::Off {
12708                RealtimeState::Disabled
12709            } else {
12710                RealtimeState::Disconnected
12711            },
12712            None,
12713        );
12714        self.presence.clear(); // §8.6.1: presence is per-connection
12715    }
12716
12717    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
12718    /// document for `scope_key`. Requires a live socket; the document is
12719    /// ephemeral (lost on disconnect). Authorization is the connection's
12720    /// registration (§8.6.3) — an unheld key is rejected loudly by the
12721    /// server with `presence.forbidden`.
12722    pub fn set_presence(
12723        &mut self,
12724        transport: &mut dyn Transport,
12725        scope_key: &str,
12726        doc: Option<&Value>,
12727    ) -> Result<(), String> {
12728        if !self.realtime_connected() {
12729            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
12730        }
12731        let text = encode_presence_publish(scope_key, doc);
12732        transport
12733            .realtime_send(&text)
12734            .map_err(|e| format!("{}: {}", e.code, e.message))
12735    }
12736
12737    /// §8.6: the peers currently present on a scope key (ephemeral).
12738    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
12739        self.presence
12740            .get(scope_key)
12741            .map(|peers| peers.values().cloned().collect())
12742            .unwrap_or_default()
12743    }
12744
12745    /// §8.6 apply an inbound presence fanout to the local map.
12746    fn apply_presence(
12747        &mut self,
12748        scope_key: String,
12749        kind: Option<PresenceKind>,
12750        actor_id: Option<String>,
12751        client_id: Option<String>,
12752        doc: Option<Value>,
12753        error: Option<String>,
12754    ) {
12755        // The publisher-directed error variant is out-of-band; nothing to
12756        // record in the peer map.
12757        if error.is_some() {
12758            return;
12759        }
12760        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
12761            return;
12762        };
12763        let peer_key = format!("{actor_id} {client_id}");
12764        match kind {
12765            PresenceKind::Leave => {
12766                if let Some(peers) = self.presence.get_mut(&scope_key) {
12767                    peers.remove(&peer_key);
12768                    if peers.is_empty() {
12769                        self.presence.remove(&scope_key);
12770                    }
12771                }
12772            }
12773            _ => {
12774                let doc = match doc {
12775                    Some(Value::Object(_)) => doc.unwrap(),
12776                    _ => return,
12777                };
12778                self.presence.entry(scope_key).or_default().insert(
12779                    peer_key,
12780                    PresencePeer {
12781                        actor_id,
12782                        client_id,
12783                        doc,
12784                    },
12785                );
12786            }
12787        }
12788    }
12789
12790    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
12791    pub fn on_realtime_text(&mut self, text: &str) {
12792        match parse_control(text) {
12793            Ok(ControlMessage::Hello { requires_sync, .. }) => {
12794                if requires_sync {
12795                    // §8.1: pull before trusting the socket for continuity.
12796                    self.set_sync_needed(true, true);
12797                }
12798            }
12799            Ok(ControlMessage::Presence {
12800                scope_key,
12801                kind,
12802                actor_id,
12803                client_id,
12804                doc,
12805                error,
12806                ..
12807            }) => {
12808                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
12809            }
12810            Ok(ControlMessage::Wake { .. }) => {
12811                // §8.3: any wake-up means "run a pull soon", never data.
12812                self.set_sync_needed(true, true);
12813            }
12814            _ => {}
12815        }
12816    }
12817
12818    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
12819    /// a pull response per section; an unapplied delta is a wake-up.
12820    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
12821        if self.stopped {
12822            return;
12823        }
12824        #[cfg(feature = "bench-internals")]
12825        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
12826        let message = match decode_message(bytes) {
12827            Ok(m) if m.msg_kind == MsgKind::Response => m,
12828            _ => {
12829                self.set_sync_needed(true, true);
12830                return;
12831            }
12832        };
12833        #[cfg(feature = "bench-internals")]
12834        drop(decode_phase);
12835        let mut frames = message.frames.into_iter();
12836        let mut applied_cursor: Option<i64> = None;
12837        let mut any_covered = false;
12838        let mut dropped = false;
12839        while let Some(frame) = frames.next() {
12840            let Frame::SubStart {
12841                id,
12842                status,
12843                effective_scopes,
12844                ..
12845            } = frame
12846            else {
12847                continue;
12848            };
12849            let mut body = Vec::new();
12850            let mut next_cursor: Option<i64> = None;
12851            for inner in frames.by_ref() {
12852                match inner {
12853                    Frame::SubEnd {
12854                        next_cursor: nc, ..
12855                    } => {
12856                        next_cursor = Some(nc);
12857                        break;
12858                    }
12859                    Frame::Unknown { .. } => {}
12860                    other => body.push(other),
12861                }
12862            }
12863            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
12864                dropped = true;
12865                continue;
12866            };
12867            let sub = &self.subs[sub_index];
12868            // §8.2: only locally active, not mid-bootstrap subscriptions
12869            // apply; skipped sections are repaired by the next pull.
12870            if status != SubStatus::Active
12871                || sub.state != SubState::Active
12872                || sub.bootstrap_state.is_some()
12873                || !sub.synced_once
12874            {
12875                dropped = true;
12876                continue;
12877            }
12878            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
12879                // Idempotent redelivery of an already-covered window.
12880                any_covered = true;
12881                continue;
12882            }
12883            let previous_effective = self.subs[sub_index].effective.clone();
12884            let previous_cursor = self.subs[sub_index].cursor;
12885            self.subs[sub_index].effective = Some(effective_scopes);
12886            let mut failed = false;
12887            for inner in body {
12888                match inner {
12889                    Frame::Commit {
12890                        tables, changes, ..
12891                    } => {
12892                        if self.apply_commit_frame(&tables, &changes).is_err() {
12893                            failed = true;
12894                            break;
12895                        }
12896                    }
12897                    _ => {
12898                        failed = true;
12899                        break;
12900                    }
12901                }
12902            }
12903            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
12904                self.apply_sub_end(sub_index, cursor, None)
12905                    .ok()
12906                    .map(|()| cursor)
12907            });
12908            let Some(next_cursor) = committed else {
12909                self.subs[sub_index].effective = previous_effective;
12910                self.subs[sub_index].cursor = previous_cursor;
12911                dropped = true;
12912                continue;
12913            };
12914            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
12915        }
12916        if let Some(cursor) = applied_cursor {
12917            // §8.2 ack point: the highest applied SUB_END.nextCursor.
12918            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12919            let _ = transport.realtime_send(&ack);
12920        } else if !any_covered || dropped {
12921            // §8.2: a delta not applied at all is treated as a wake-up.
12922            self.set_sync_needed(true, true);
12923        }
12924    }
12925
12926    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
12927    /// cursor across active, non-bootstrapping subscriptions that have
12928    /// synced at least once. No such subscription, no ack.
12929    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
12930        if !self.realtime_connected() {
12931            return;
12932        }
12933        let floor = self
12934            .subs
12935            .iter()
12936            .filter(|s| {
12937                s.state == SubState::Active
12938                    && s.bootstrap_state.is_none()
12939                    && s.synced_once
12940                    && s.cursor >= 0
12941            })
12942            .map(|s| s.cursor)
12943            .min();
12944        if let Some(cursor) = floor {
12945            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12946            let _ = transport.realtime_send(&ack);
12947        }
12948    }
12949}
12950
12951#[cfg(test)]
12952mod previous_version_wiring_tests {
12953    use super::*;
12954    use serde_json::{json, Value};
12955
12956    fn previous_version_schema(version: i32) -> Value {
12957        json!({
12958            "version": version,
12959            "tables": [{
12960                "name": "tasks",
12961                "primaryKey": "id",
12962                "columns": [
12963                    { "name": "id", "type": "string", "nullable": false },
12964                    { "name": "note", "type": "string", "nullable": true }
12965                ],
12966                "scopes": []
12967            }]
12968        })
12969    }
12970
12971    fn previous_version_limits() -> crate::api::ClientLimits {
12972        crate::api::ClientLimits {
12973            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
12974                enabled: true,
12975                ..Default::default()
12976            }),
12977            ..Default::default()
12978        }
12979    }
12980
12981    /// The configuration the live client carries, for a direct capture call.
12982    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
12983        previous_version_limits()
12984            .previous_version_context
12985            .expect("configured")
12986    }
12987
12988    /// Capture the container the way a bump would, so a read test starts from a
12989    /// container this client's own state will then decide about.
12990    fn recapture_previous_version(client: &SyncClient) {
12991        let replica_path = client
12992            .previous_version_replica_path()
12993            .expect("file-backed replica");
12994        let outcome = crate::previous_version::capture_previous_version_from_replica(
12995            &client.conn,
12996            &replica_path,
12997            2,
12998            2,
12999            &previous_version_config(),
13000            1,
13001        )
13002        .expect("capture");
13003        assert!(matches!(
13004            outcome,
13005            crate::previous_version::CaptureOutcome::Captured(_)
13006        ));
13007    }
13008
13009    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
13010        Subscription {
13011            id: "wiring-sub".to_owned(),
13012            table: "tasks".to_owned(),
13013            requested: Vec::new(),
13014            params: None,
13015            cursor,
13016            bootstrap_state: bootstrap_state.map(str::to_owned),
13017            state,
13018            reason_code: None,
13019            effective: None,
13020            synced_once: false,
13021        }
13022    }
13023
13024    #[test]
13025    fn previous_version_read_reasons_come_from_live_client_state() {
13026        use crate::previous_version::{
13027            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
13028        };
13029        let path = std::env::temp_dir()
13030            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
13031            .to_string_lossy()
13032            .into_owned();
13033        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
13034        {
13035            let mut setup = SyncClient::open_path(
13036                "wiring".to_owned(),
13037                &previous_version_schema(1),
13038                previous_version_limits(),
13039                &path,
13040            )
13041            .expect("v1 install");
13042            setup
13043                .mutate(vec![Mutation::Upsert {
13044                    table: "tasks".to_owned(),
13045                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
13046                    base_version: None,
13047                }])
13048                .expect("seed a row");
13049        }
13050        let mut client = SyncClient::open_path(
13051            "wiring".to_owned(),
13052            &previous_version_schema(2),
13053            previous_version_limits(),
13054            &path,
13055        )
13056        .expect("v2 bump captures");
13057        assert!(std::path::Path::new(&container).exists());
13058        let spec = PreviousVersionReadSpec {
13059            table: "tasks".to_owned(),
13060            ..Default::default()
13061        };
13062
13063        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
13064        recapture_previous_version(&client);
13065        client.lease_state = Some(LeaseState {
13066            lease_id: None,
13067            expires_at_ms: None,
13068            error_code: Some("lease.expired".to_owned()),
13069        });
13070        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13071        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
13072        assert!(!snapshot.available);
13073        assert!(!std::path::Path::new(&container).exists());
13074
13075        // An expired lease uses the client clock (injected, no sleeps).
13076        recapture_previous_version(&client);
13077        client.set_now_ms(1_000);
13078        client.lease_state = Some(LeaseState {
13079            lease_id: Some("lease".to_owned()),
13080            expires_at_ms: Some(1_000),
13081            error_code: None,
13082        });
13083        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13084        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
13085        assert!(!std::path::Path::new(&container).exists());
13086        client.lease_state = None;
13087
13088        // A revoked subscription ⇒ scope-revoked.
13089        recapture_previous_version(&client);
13090        client.subs.push(subscription(SubState::Revoked, 0, None));
13091        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13092        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
13093        assert!(!std::path::Path::new(&container).exists());
13094        client.subs.clear();
13095
13096        // Coverage completion: every ACTIVE subscription has a cursor and no
13097        // resume token — a still-resuming subscription is not completion.
13098        recapture_previous_version(&client);
13099        client
13100            .subs
13101            .push(subscription(SubState::Active, 0, Some("resume")));
13102        assert!(!client.previous_version_coverage_complete());
13103        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13104        assert!(snapshot.available);
13105        assert_eq!(snapshot.previous_version, Some(2));
13106        assert_eq!(snapshot.rows.len(), 1);
13107
13108        recapture_previous_version(&client);
13109        client.subs[0].bootstrap_state = None;
13110        assert!(client.previous_version_coverage_complete());
13111        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13112        assert_eq!(
13113            snapshot.reason,
13114            Some(PreviousVersionReason::CoverageComplete)
13115        );
13116        assert!(!std::path::Path::new(&container).exists());
13117        client.subs.clear();
13118
13119        // With no active subscription at all, coverage is NOT complete.
13120        assert!(!client.previous_version_coverage_complete());
13121
13122        drop(client);
13123        let _ = std::fs::remove_file(&path);
13124    }
13125
13126    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
13127    /// come from live state; this proves the OTHER caller —
13128    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
13129    /// actually removes the container for lease-inactivity, coverage
13130    /// completion and scope revocation, and leaves it alone while the
13131    /// replacement bootstrap is still resuming.
13132    #[test]
13133    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
13134        let path = std::env::temp_dir()
13135            .join(format!(
13136                "syncular-prev-lifetime-{}.db",
13137                uuid::Uuid::new_v4()
13138            ))
13139            .to_string_lossy()
13140            .into_owned();
13141        let container = format!(
13142            "{path}{}",
13143            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
13144        );
13145        let exists = || std::path::Path::new(&container).exists();
13146        {
13147            let mut setup = SyncClient::open_path(
13148                "lifetime".to_owned(),
13149                &previous_version_schema(1),
13150                previous_version_limits(),
13151                &path,
13152            )
13153            .expect("v1 install");
13154            setup
13155                .mutate(vec![Mutation::Upsert {
13156                    table: "tasks".to_owned(),
13157                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
13158                    base_version: None,
13159                }])
13160                .expect("seed a row");
13161        }
13162        let mut client = SyncClient::open_path(
13163            "lifetime".to_owned(),
13164            &previous_version_schema(2),
13165            previous_version_limits(),
13166            &path,
13167        )
13168        .expect("v2 bump captures");
13169        // Pin the clock so the 24h TTL never fires in this test (the TTL has
13170        // its own coverage); the capture record's createdAtMs is 1.
13171        client.set_now_ms(1_000);
13172        assert!(exists());
13173
13174        // §7.3.5 lease error ⇒ the lifetime check drops the container.
13175        client.lease_state = Some(LeaseState {
13176            lease_id: None,
13177            expires_at_ms: None,
13178            error_code: Some("lease.expired".to_owned()),
13179        });
13180        client.previous_version_lifetime_check();
13181        assert!(!exists());
13182        client.lease_state = None;
13183
13184        // §7.4.5 coverage completion (every active sub has a cursor and no
13185        // resume token) ⇒ drop.
13186        recapture_previous_version(&client);
13187        client.subs.push(subscription(SubState::Active, 0, None));
13188        client.previous_version_lifetime_check();
13189        assert!(!exists());
13190        client.subs.clear();
13191
13192        // §3.3 scope revocation ⇒ drop.
13193        recapture_previous_version(&client);
13194        client.subs.push(subscription(SubState::Revoked, 0, None));
13195        client.previous_version_lifetime_check();
13196        assert!(!exists());
13197        client.subs.clear();
13198
13199        // Negative control: a still-resuming active subscription is NOT
13200        // coverage completion, so the container survives the same call.
13201        recapture_previous_version(&client);
13202        client
13203            .subs
13204            .push(subscription(SubState::Active, 0, Some("resume")));
13205        client.previous_version_lifetime_check();
13206        assert!(exists());
13207        client.subs.clear();
13208
13209        drop(client);
13210        let _ = std::fs::remove_file(&path);
13211        let _ = std::fs::remove_file(&container);
13212        let _ = std::fs::remove_file(format!("{container}-wal"));
13213        let _ = std::fs::remove_file(format!("{container}-shm"));
13214    }
13215}