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};
11#[cfg(test)]
12use ssp2::encode_message;
13#[cfg(test)]
14use std::cell::Cell;
15use std::cell::RefCell;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
17
18use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, OptionalExtension};
20use serde::{Deserialize, Serialize};
21use serde_json::{json, Map, Value};
22use sha2::{Digest, Sha256};
23use ssp2::decode::WIRE_VERSION;
24use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
25use ssp2::primitives::RawJson;
26use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
27use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
28
29use crate::api::{
30    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
31    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
32    ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
33    CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
34    CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
35    DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
36    FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
37    LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
38    PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
39    RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
40    RetainedUniqueConflict, RowState, SchemaFloor, SubscriptionStateView, SyncIntent, SyncOutcome,
41    SyncReport, SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage,
42    WindowState, WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43    MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49    capture_previous_version_from_replica, first_incompatibility,
50    reconcile_previous_version_at_boot, set_local_schema_descriptor,
51    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58    encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
59    normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
60};
61
62/// §4.2 default: rows and SQLite images, both applied in committed chunks.
63/// Bit 3 (signed URLs, §5.4) is
64/// added per transport capability at request-build time.
65const DEFAULT_ACCEPT: u8 = 0b0111;
66const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
67const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
68const ACCEPT_SQLITE: u8 = 1 << 2;
69const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
70const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
71
72/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
73pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
74const LOCAL_REVISION_KEY: &str = "localRevision";
75const CLIENT_ID_KEY: &str = "clientId";
76const LEASE_STATE_KEY: &str = "leaseState";
77const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
78const LOG_EPOCH_KEY: &str = "logEpoch";
79/// Persisted fail-closed quarantine marker: present while a security preflight
80/// is pending and `activateSecurity` has yet to run. Storing it in the replica
81/// keeps the gate with the data it protects, so a rebuilt host handle reopening
82/// the same file returns in preflight even though its in-memory state is fresh.
83const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
84const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
85const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
86/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
87/// the new schema after a bump. Never a wire code (§10.3).
88const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
89/// §5.11 client-local code: an encode at the push seam resolved no usable key
90/// id or named an unknown key. Never a wire code (§10.3).
91const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
92
93#[derive(Debug, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95struct PersistedLocalDataRebootstrapReceipt {
96    version: u8,
97    retained_commits: u64,
98    reset_subscriptions: u64,
99}
100
101fn invalid_local_rebootstrap_receipt() -> String {
102    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
103}
104
105fn encode_local_rebootstrap_receipt(
106    retained_commits: usize,
107    reset_subscriptions: usize,
108) -> Result<String, String> {
109    let retained_commits =
110        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
111    let reset_subscriptions =
112        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
113    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
114        return Err(invalid_local_rebootstrap_receipt());
115    }
116    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
117        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
118        retained_commits,
119        reset_subscriptions,
120    })
121    .map_err(|_| invalid_local_rebootstrap_receipt())
122}
123
124fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
125    // Pre-0.15.36 markers proved application but did not retain the receipt.
126    if value == "v1" {
127        return Ok((0, 0));
128    }
129    let receipt: PersistedLocalDataRebootstrapReceipt =
130        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
131    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
132        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
133        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
134    {
135        return Err(invalid_local_rebootstrap_receipt());
136    }
137    Ok((
138        usize::try_from(receipt.retained_commits)
139            .map_err(|_| invalid_local_rebootstrap_receipt())?,
140        usize::try_from(receipt.reset_subscriptions)
141            .map_err(|_| invalid_local_rebootstrap_receipt())?,
142    ))
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum SubState {
147    Active,
148    Revoked,
149    Failed,
150}
151
152#[cfg(test)]
153mod observation_tests {
154    use super::*;
155    use crate::native_transport::HostTransport;
156    use crate::values::encode_row_json;
157    use serde_json::json;
158
159    #[test]
160    fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
161        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
162            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
163        ]}]});
164        for name in [
165            "secret",
166            "missing",
167            "*",
168            "id FROM authority; DELETE FROM authority",
169            "_syncular_version",
170        ] {
171            let limits = ClientLimits {
172                authority_reads: vec![crate::api::AuthorityReadDeclaration {
173                    table: "authority".into(),
174                    columns: vec!["id".into(), "actor_id".into(), name.into()],
175                    scopes: [("actor_id".into(), vec!["a".into()])].into(),
176                }],
177                ..Default::default()
178            };
179            let error = SyncClient::new("authority".into(), &schema, limits)
180                .err()
181                .expect("forbidden policy");
182            assert!(
183                error.starts_with("client.authority_read_forbidden:"),
184                "{error}"
185            );
186        }
187    }
188
189    #[test]
190    fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
191        let path = std::env::temp_dir().join(format!(
192            "syncular-authority-snapshot-{}.db",
193            uuid::Uuid::new_v4()
194        ));
195        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
196            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
197        ]}]});
198        let limits = ClientLimits {
199            authority_reads: vec![crate::api::AuthorityReadDeclaration {
200                table: "authority".into(),
201                columns: vec!["id".into(), "actor_id".into(), "revision".into()],
202                scopes: [("actor_id".into(), vec!["a".into()])].into(),
203            }],
204            ..Default::default()
205        };
206        let mut client =
207            SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
208                .unwrap();
209        client.begin_security_preflight();
210        let update = |conn: &Connection, revision: i64| {
211            conn.execute_batch("BEGIN IMMEDIATE").unwrap();
212            conn.execute(
213                "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
214                [revision],
215            )
216            .unwrap();
217            conn.execute(
218                "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
219                [revision.to_string()],
220            )
221            .unwrap();
222            conn.execute("INSERT OR REPLACE INTO _syncular_subscriptions VALUES('s','authority',?1)", [json!({"requested": {"actor_id": ["a"]}, "effectiveScopes": {"actor_id": ["a"]}, "status": "active", "cursor": revision}).to_string()]).unwrap();
223            conn.execute_batch("COMMIT").unwrap();
224        };
225        update(&client.conn, 1);
226        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
227        let writer_barrier = barrier.clone();
228        let writer_path = path.clone();
229        let writer = std::thread::spawn(move || {
230            let conn = Connection::open(writer_path).unwrap();
231            conn.busy_timeout(std::time::Duration::from_secs(5))
232                .unwrap();
233            writer_barrier.wait();
234            for revision in 2..=100 {
235                update(&conn, revision);
236            }
237        });
238        barrier.wait();
239        for _ in 0..100 {
240            let snapshot = client.authority_snapshot().unwrap();
241            let revision = snapshot["revision"]
242                .as_str()
243                .unwrap()
244                .parse::<i64>()
245                .unwrap();
246            assert_eq!(
247                snapshot["tables"][0]["rows"][0]["values"]["revision"],
248                revision
249            );
250            assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
251            assert_eq!(snapshot["complete"], true);
252        }
253        writer.join().unwrap();
254        assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
255        drop(client);
256        let _ = std::fs::remove_file(path);
257    }
258
259    fn client() -> SyncClient {
260        SyncClient::new(
261            "retry-test".to_owned(),
262            &json!({
263                "version": 1,
264                "tables": [{
265                    "name": "tasks",
266                    "primaryKey": "id",
267                    "columns": [
268                        { "name": "id", "type": "string", "nullable": false },
269                        { "name": "project_id", "type": "string", "nullable": false }
270                    ],
271                    "scopes": [{ "pattern": "project:{project_id}" }]
272                }]
273            }),
274            ClientLimits::default(),
275        )
276        .expect("test client")
277    }
278
279    #[cfg(feature = "e2ee")]
280    #[test]
281    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
282        let schema = json!({
283            "version": 1,
284            "tables": [{
285                "name": "secrets",
286                "primaryKey": "id",
287                "columns": [
288                    { "name": "id", "type": "string", "nullable": false },
289                    { "name": "encryption_key_id", "type": "string", "nullable": true },
290                    { "name": "note", "type": "bytes", "nullable": true,
291                      "encrypted": true, "declaredType": "string" }
292                ],
293                "scopes": []
294            }]
295        });
296        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
297            .expect("test client");
298        let mut config = crate::values::EncryptionConfig::default();
299        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
300        config
301            .key_id_columns
302            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
303        client.set_encryption(config);
304        // The row exists, but its key selector is absent. The encrypted edit
305        // authors locally and fails at the push seam.
306        client
307            .write_base_row(
308                "secrets",
309                &vec![Some(ColumnValue::String("ghost".into())), None, None],
310                1,
311            )
312            .unwrap();
313        client.rebuild_overlay();
314        let commit_id = client
315            .patch(
316                "secrets",
317                "ghost",
318                Map::from_iter([("note".to_owned(), json!("x"))]),
319                None,
320            )
321            .expect("patch records without an author-time encrypt failure");
322        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
323        // ...and the push seam raises a durable local rejection, not a throw.
324        assert!(client.drop_unencodable_outbox().unwrap());
325        assert!(client.pending_commit_ids().is_empty());
326        let rejections = client.rejections();
327        assert_eq!(rejections.len(), 1);
328        assert_eq!(rejections[0].code, "client.encrypt_failed");
329        assert_eq!(rejections[0].client_commit_id, commit_id);
330        assert_eq!(
331            client
332                .commit_outcome(&commit_id)
333                .unwrap()
334                .map(|outcome| outcome.status),
335            Some(CommitOutcomeStatus::Rejected)
336        );
337    }
338
339    #[test]
340    fn patch_with_a_scope_column_matches_the_stored_local_row() {
341        let mut client = client();
342        client.create_synced_tables().unwrap();
343        client
344            .conn
345            .execute(
346                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
347                [],
348            )
349            .unwrap();
350
351        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
352        // the scope column leaves the presence set.
353        client
354            .patch(
355                "tasks",
356                "t1",
357                Map::from_iter([
358                    ("project_id".to_owned(), json!("p1")),
359                    ("id".to_owned(), json!("t1")),
360                ]),
361                None,
362            )
363            .expect("an equal scope column is a no-op");
364        let values = client.outbox[0].ops[0]
365            .values
366            .clone()
367            .expect("the patch records values");
368        assert_eq!(values.get("id"), Some(&json!("t1")));
369        assert!(!values.contains_key("project_id"));
370
371        // A differing value stays rejected.
372        let differing = client.patch(
373            "tasks",
374            "t1",
375            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
376            None,
377        );
378        assert!(differing
379            .expect_err("a differing scope column is rejected")
380            .contains("patch cannot write scope column"));
381
382        // An absent local row leaves nothing to prove equality against, so
383        // the patch fails closed.
384        let absent = client.patch(
385            "tasks",
386            "ghost",
387            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
388            None,
389        );
390        assert!(absent
391            .expect_err("an absent local row is rejected")
392            .starts_with("sync.row_missing:"));
393        assert_eq!(client.pending_commit_ids().len(), 1);
394    }
395
396    #[test]
397    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
398        let schema = json!({
399            "version": 1,
400            "tables": [
401                {
402                    "name": "tenants",
403                    "primaryKey": "tenant_id",
404                    "columns": [
405                        { "name": "tenant_id", "type": "string", "nullable": false },
406                        { "name": "body", "type": "string", "nullable": false }
407                    ],
408                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
409                },
410                {
411                    "name": "buckets",
412                    "primaryKey": "id",
413                    "columns": [
414                        { "name": "id", "type": "string", "nullable": false },
415                        { "name": "bucket", "type": "float", "nullable": false },
416                        { "name": "body", "type": "string", "nullable": false }
417                    ],
418                    "scopes": [{ "pattern": "bucket:{bucket}" }]
419                }
420            ]
421        });
422        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
423            .expect("test client");
424        client.create_synced_tables().unwrap();
425
426        // A primary key that is also a scope column still needs a local base.
427        client
428            .conn
429            .execute(
430                "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
431                [],
432            )
433            .unwrap();
434        client
435            .patch(
436                "tenants",
437                "t1",
438                Map::from_iter([
439                    ("tenant_id".to_owned(), json!("t1")),
440                    ("body".to_owned(), json!("created")),
441                ]),
442                None,
443            )
444            .expect("the primary-key scope column is proven equal by construction");
445
446        // A stored REAL scope value coerces from a supplied integer.
447        client
448            .conn
449            .execute(
450                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
451                [],
452            )
453            .unwrap();
454        client
455            .patch(
456                "buckets",
457                "b1",
458                Map::from_iter([
459                    ("bucket".to_owned(), json!(2)),
460                    ("body".to_owned(), json!("coerced")),
461                ]),
462                None,
463            )
464            .expect("the stored REAL value coerces from a supplied integer");
465        let values = client.outbox[1].ops[0]
466            .values
467            .clone()
468            .expect("the patch records values");
469        assert!(!values.contains_key("bucket"));
470
471        // A differing value stays rejected.
472        let differing = client.patch(
473            "buckets",
474            "b1",
475            Map::from_iter([("bucket".to_owned(), json!(3))]),
476            None,
477        );
478        assert!(differing
479            .expect_err("a differing scope value is rejected")
480            .contains("patch cannot write scope column"));
481    }
482
483    #[cfg(feature = "e2ee")]
484    #[test]
485    fn stored_key_fallback_resolves_an_integer_primary_key() {
486        let schema = json!({
487            "version": 1,
488            "tables": [{
489                "name": "patients",
490                "primaryKey": "id",
491                "columns": [
492                    { "name": "id", "type": "integer", "nullable": false },
493                    { "name": "encryption_key_id", "type": "string", "nullable": true },
494                    { "name": "note", "type": "bytes", "nullable": true,
495                      "encrypted": true, "declaredType": "string" }
496                ],
497                "scopes": []
498            }]
499        });
500        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
501            .expect("test client");
502        client.create_synced_tables().unwrap();
503        client
504            .conn
505            .execute(
506                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
507                 VALUES (5, 'k1', NULL, 1)",
508                [],
509            )
510            .unwrap();
511        let mut config = crate::values::EncryptionConfig::default();
512        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
513        config
514            .key_id_columns
515            .insert("patients".to_owned(), "encryption_key_id".to_owned());
516        client.set_encryption(config);
517        let table = client.schema.table("patients").unwrap();
518        let fallback = client
519            .stored_key_fallback(table, "5")
520            .expect("the stored integer key resolves the fallback");
521        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
522    }
523
524    #[test]
525    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
526        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
527            {"name":"id","type":"string","nullable":false},
528            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
529        for state in ["absent", "cached", "pinned"] {
530            let path = std::env::temp_dir().join(format!(
531                "syncular-blob-stage-{}.sqlite",
532                uuid::Uuid::new_v4()
533            ));
534            let mut client = SyncClient::open_path(
535                "stage-test".into(),
536                &schema,
537                Default::default(),
538                path.to_str().unwrap(),
539            )
540            .unwrap();
541            let bytes = b"atomic stage";
542            if state != "absent" {
543                client
544                    .upload_blob(bytes, Some("text/plain".into()), None)
545                    .unwrap();
546                if state == "cached" {
547                    client
548                        .conn
549                        .execute("DELETE FROM _syncular_blob_uploads", [])
550                        .unwrap();
551                }
552            }
553            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
554            client
555                .conn
556                .execute_batch(
557                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
558                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
559                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
560                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
561                )
562                .unwrap();
563            let error = client
564                .upload_blob(bytes, Some("application/octet-stream".into()), None)
565                .unwrap_err();
566            assert!(error.contains("injected stage failure"), "{state}: {error}");
567            assert!(client.conn.is_autocommit());
568            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
569            drop(client);
570            let mut reopened = SyncClient::open_path(
571                "stage-test".into(),
572                &schema,
573                Default::default(),
574                path.to_str().unwrap(),
575            )
576            .unwrap();
577            let durable = reopened
578                .query("SELECT * FROM _syncular_blobs", &[])
579                .unwrap();
580            reopened
581                .conn
582                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
583                .unwrap();
584            let retried = reopened.upload_blob(bytes, None, None).unwrap();
585            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
586            assert_eq!(
587                reopened
588                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
589                    .unwrap(),
590                vec![Map::from_iter([(
591                    "blob_id".into(),
592                    Value::from(blob_id_for(bytes)),
593                )])]
594            );
595            drop(reopened);
596            std::fs::remove_file(&path).unwrap();
597            assert_eq!(durable, bodies, "{state}: reopened body state");
598            assert_eq!(immediate, bodies, "{state}: immediate body state");
599        }
600    }
601
602    #[test]
603    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
604        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
605            {"name":"id","type":"string","nullable":false},
606            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
607        for fault in [
608            "missing",
609            "body-type",
610            "length",
611            "hash",
612            "pin-metadata",
613            "read",
614            "pin-delete",
615        ] {
616            let path = std::env::temp_dir().join(format!(
617                "syncular-blob-upload-{}.sqlite",
618                uuid::Uuid::new_v4()
619            ));
620            let mut client = SyncClient::open_path(
621                "upload-test".into(),
622                &schema,
623                Default::default(),
624                path.to_str().unwrap(),
625            )
626            .unwrap();
627            let bytes = b"durable upload";
628            let ref_value = client
629                .upload_blob(bytes, Some("text/plain".into()), None)
630                .unwrap();
631            let commit = client
632                .mutate(vec![Mutation::Upsert {
633                    table: "attachments".into(),
634                    values: Map::from_iter([
635                        ("id".into(), Value::from("pending")),
636                        (
637                            "file".into(),
638                            Value::from(format!(
639                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
640                                blob_id_for(bytes),
641                                bytes.len()
642                            )),
643                        ),
644                    ]),
645                    base_version: None,
646                }])
647                .unwrap();
648            client
649                .conn
650                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
651                .unwrap();
652            client.conn.execute_batch(match fault {
653                "missing" => "DELETE FROM _syncular_blobs",
654                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
655                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
656                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
657                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
658                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
659                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
660            }).unwrap();
661            let state = if fault == "read" {
662                Vec::new()
663            } else {
664                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
665            };
666            let mut transport = CountingRealtimeTransport::default();
667            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
668            assert_eq!(
669                error.code,
670                if matches!(fault, "read" | "pin-delete") {
671                    "client.failed"
672                } else {
673                    "sync.local_corrupt"
674                },
675                "{fault}"
676            );
677            assert_eq!(
678                transport.blob_uploads.len(),
679                usize::from(fault == "pin-delete"),
680                "{fault}: corrupt bytes must never be uploaded"
681            );
682            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
683            if fault != "read" {
684                assert_eq!(
685                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
686                    state
687                );
688            } else {
689                client
690                    .conn
691                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
692                    .unwrap();
693            }
694            drop(client);
695            let mut reopened = SyncClient::open_path(
696                "upload-test".into(),
697                &schema,
698                Default::default(),
699                path.to_str().unwrap(),
700            )
701            .unwrap();
702            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
703            if fault != "read" {
704                assert_eq!(
705                    reopened
706                        .query("SELECT * FROM _syncular_blobs", &[])
707                        .unwrap(),
708                    state
709                );
710            }
711            if fault == "pin-delete" {
712                reopened
713                    .conn
714                    .execute_batch("DROP TRIGGER fail_pin_delete")
715                    .unwrap();
716            }
717            if fault == "pin-metadata" {
718                reopened
719                    .conn
720                    .execute(
721                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
722                        [],
723                    )
724                    .unwrap();
725            }
726            reopened
727                .conn
728                .execute_batch(
729                    "DELETE FROM _syncular_blobs;
730                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
731                )
732                .unwrap();
733            transport.blob_uploads.clear();
734            reopened.flush_blob_uploads(&mut transport).unwrap();
735            assert_eq!(
736                transport.blob_uploads,
737                [(blob_id_for(bytes), bytes.to_vec())]
738            );
739            assert_eq!(
740                reopened
741                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
742                    .unwrap(),
743                Vec::<Map<String, Value>>::new()
744            );
745            assert_eq!(reopened.pending_commit_ids(), [commit]);
746            assert_eq!(
747                reopened
748                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
749                    .unwrap()
750                    .unwrap()
751                    .bytes,
752                bytes
753            );
754            drop(reopened);
755            std::fs::remove_file(path).unwrap();
756        }
757    }
758
759    #[test]
760    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
761        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
762            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
763            "scopes":[{"pattern":"project:{project_id}"}]}]});
764        for realtime in [false, true] {
765            for fault in [
766                "none",
767                "row",
768                "revision",
769                "commit",
770                "cursor",
771                "error",
772                "missing-end",
773            ] {
774                if realtime && fault == "missing-end" {
775                    continue;
776                }
777                let path = std::env::temp_dir()
778                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
779                let mut client = SyncClient::open_path(
780                    "frame-test".into(),
781                    &schema,
782                    Default::default(),
783                    path.to_str().unwrap(),
784                )
785                .unwrap();
786                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
787                client
788                    .subscribe(
789                        "tasks".into(),
790                        "tasks".into(),
791                        vec![("project_id".into(), vec!["p1".into()])],
792                        None,
793                    )
794                    .unwrap();
795                client.subs[0].cursor = 0;
796                client.subs[0].synced_once = true;
797                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
798                client.persist_sub(&client.subs[0]).unwrap();
799                client.realtime_state = RealtimeState::Connected;
800                client.realtime_reason_code = None;
801                client.drain_change_batches();
802                let mut transport = CountingRealtimeTransport::default();
803                let (_, meta) = client.build_request(false);
804                let mut response = Message {
805                    wire_version: WIRE_VERSION,
806                    msg_kind: MsgKind::Response,
807                    frames: vec![
808                        Frame::RespHeader {
809                            required_schema_version: None,
810                            latest_schema_version: None,
811                            log_epoch: Some("epoch-1".into()),
812                            reset_required: Some(false),
813                        },
814                        Frame::SubStart {
815                            id: "tasks".into(),
816                            status: SubStatus::Active,
817                            reason_code: "".into(),
818                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
819                            bootstrap: false,
820                        },
821                    ],
822                };
823                for (index, id) in ["first", "second"].iter().enumerate() {
824                    let values = Map::from_iter([
825                        ("id".into(), Value::from(*id)),
826                        ("project_id".into(), Value::from("p1")),
827                    ]);
828                    response.frames.push(Frame::Commit {
829                        commit_seq: index as i64 + 1,
830                        actor_id: "writer".into(),
831                        created_at_ms: 1,
832                        tables: vec!["tasks".into()],
833                        changes: vec![ssp2::model::Change {
834                            table_index: 0,
835                            row_id: (*id).into(),
836                            op: Op::Upsert,
837                            row_version: Some(index as i64 + 1),
838                            scopes: vec![("project_id".into(), "p1".into())],
839                            row: Some(
840                                encode_row_json(
841                                    client.schema.table("tasks").unwrap(),
842                                    id,
843                                    &values,
844                                    &client.encryption,
845                                )
846                                .unwrap(),
847                            ),
848                        }],
849                    });
850                }
851                response.frames.push(Frame::SubEnd {
852                    next_cursor: 2,
853                    bootstrap_state: None,
854                });
855                let valid = response.clone();
856                match fault {
857                    "row" => {
858                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
859                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
860                    },
861                    "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(),
862                    "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(),
863                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
864                    "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});},
865                    "missing-end" => {response.frames.truncate(3);},
866                    _ => {}
867                }
868                if realtime {
869                    client.on_realtime_binary(&mut transport, &encode_message(&response));
870                } else {
871                    assert_eq!(
872                        matches!(
873                            client.process_response(&mut transport, response, &meta),
874                            SyncOutcome::Ok(_)
875                        ),
876                        fault == "none",
877                        "{fault}"
878                    );
879                }
880                assert!(
881                    client.conn.is_autocommit(),
882                    "no open transaction after {realtime}/{fault}"
883                );
884                let count = if matches!(fault, "none" | "cursor") {
885                    2
886                } else {
887                    1
888                };
889                let expected_revision = count + u64::from(realtime && fault != "none");
890                assert_eq!(
891                    client.local_revision(),
892                    expected_revision,
893                    "{realtime}/{fault}"
894                );
895                let batches = client.drain_change_batches();
896                assert_eq!(
897                    batches.len(),
898                    expected_revision as usize,
899                    "{realtime}/{fault}"
900                );
901                let row_batches: Vec<_> = batches
902                    .iter()
903                    .filter(|batch| !batch.tables.is_empty())
904                    .collect();
905                assert_eq!(row_batches.len(), count as usize);
906                assert_eq!(
907                    row_batches
908                        .iter()
909                        .map(|b| b.revision.clone())
910                        .collect::<Vec<_>>(),
911                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
912                );
913                for table in ["tasks", "_syncular_base_tasks"] {
914                    assert_eq!(
915                        client
916                            .conn
917                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
918                                .get::<_, u64>(0))
919                            .unwrap(),
920                        count,
921                        "{realtime}/{fault}/{table}"
922                    );
923                }
924                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
925                assert_eq!(
926                    transport
927                        .messages
928                        .iter()
929                        .filter(|m| m.contains("\"ack\""))
930                        .count(),
931                    usize::from(fault == "none")
932                );
933                if matches!(fault, "revision" | "commit" | "cursor") {
934                    client
935                        .conn
936                        .execute_batch("DROP TRIGGER fail_frame")
937                        .unwrap();
938                }
939                drop(client);
940                let mut reopened = SyncClient::open_path(
941                    "frame-test".into(),
942                    &schema,
943                    Default::default(),
944                    path.to_str().unwrap(),
945                )
946                .unwrap();
947                assert_eq!(reopened.local_revision(), expected_revision);
948                assert_eq!(
949                    reopened
950                        .query("SELECT id FROM tasks ORDER BY id", &[])
951                        .unwrap()
952                        .len(),
953                    count as usize
954                );
955                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
956                let (_, meta) = reopened.build_request(false);
957                assert!(matches!(
958                    reopened.process_response(&mut transport, valid, &meta),
959                    SyncOutcome::Ok(_)
960                ));
961                assert_eq!(
962                    reopened
963                        .query("SELECT id FROM tasks ORDER BY id", &[])
964                        .unwrap()
965                        .len(),
966                    2
967                );
968                assert_eq!(reopened.subs[0].cursor, 2);
969                drop(reopened);
970                std::fs::remove_file(path).unwrap();
971            }
972        }
973    }
974
975    #[test]
976    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
977        for status in [SubStatus::Reset, SubStatus::Revoked] {
978            for fault in ["subscription", "revision"] {
979                let mut client = client();
980                client
981                    .subscribe(
982                        "tasks".into(),
983                        "tasks".into(),
984                        vec![("project_id".into(), vec!["p1".into()])],
985                        None,
986                    )
987                    .unwrap();
988                client.subs[0].cursor = 41;
989                client.subs[0].synced_once = true;
990                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
991                client.persist_sub(&client.subs[0]).unwrap();
992                client
993                    .mutate(vec![Mutation::Upsert {
994                        table: "tasks".into(),
995                        values: Map::from_iter([
996                            ("id".into(), Value::from("pending")),
997                            ("project_id".into(), Value::from("p1")),
998                        ]),
999                        base_version: None,
1000                    }])
1001                    .unwrap();
1002                let ids = client.pending_commit_ids();
1003                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1004                let revision = client.local_revision();
1005                client.drain_change_batches();
1006                let (_, meta) = client.build_request(false);
1007                client.conn.execute_batch(if fault == "subscription" {
1008                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1009                } else {
1010                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1011                }).unwrap();
1012                let mut transport = CountingRealtimeTransport::default();
1013                let mut report = SyncReport::default();
1014                // Reset changes window completeness and therefore needs a revision.
1015                if status == SubStatus::Reset {
1016                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1017                }
1018                let result = client.process_section(
1019                    &mut transport,
1020                    "tasks",
1021                    status,
1022                    "sync.scope_revoked",
1023                    vec![],
1024                    vec![],
1025                    Some((42, None)),
1026                    &meta,
1027                    &mut report,
1028                );
1029                assert!(result.is_err(), "{status:?}/{fault}");
1030                assert!(client.conn.is_autocommit());
1031                assert_eq!(client.subs[0].cursor, 41);
1032                assert_eq!(client.subs[0].state, SubState::Active);
1033                assert_eq!(client.pending_commit_ids(), ids);
1034                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1035                assert!(client.rejections.is_empty());
1036                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1037                assert_eq!(client.local_revision(), revision);
1038                assert!(client.drain_change_batches().is_empty());
1039                assert!(report.revoked.is_empty() && report.resets.is_empty());
1040            }
1041        }
1042    }
1043
1044    #[test]
1045    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1046        let mut client = client();
1047        client
1048            .subscribe(
1049                "tasks".into(),
1050                "tasks".into(),
1051                vec![("project_id".into(), vec!["p1".into()])],
1052                None,
1053            )
1054            .unwrap();
1055        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1056        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1057            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1058        let row = |id: &str| ssp2::segment::SegmentRow {
1059            server_version: 1,
1060            values: vec![
1061                Some(ColumnValue::String(id.into())),
1062                Some(ColumnValue::String("p1".into())),
1063            ],
1064        };
1065        let segment = RowsSegment {
1066            table: "tasks".into(),
1067            schema_version: 1,
1068            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1069            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1070        };
1071        assert!(client.apply_segment(0, &segment, true).is_err());
1072        assert!(client.conn.is_autocommit());
1073        assert_eq!(
1074            client
1075                .query("SELECT id FROM tasks ORDER BY id", &[])
1076                .unwrap(),
1077            vec![Map::from_iter([("id".into(), Value::from("first"))])]
1078        );
1079        assert_eq!(client.local_revision(), 1);
1080        assert_eq!(client.drain_change_batches().len(), 1);
1081        assert_eq!(client.subs[0].cursor, -1);
1082        client
1083            .conn
1084            .execute_batch("DROP TRIGGER fail_block")
1085            .unwrap();
1086        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1087        assert_eq!(client.local_revision(), 3);
1088        assert_eq!(client.drain_change_batches().len(), 2);
1089    }
1090
1091    #[test]
1092    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1093        assert_eq!(
1094            client().diagnostics_storage().blob_cache_bytes_approx,
1095            Some(0)
1096        );
1097        let client = SyncClient::new(
1098            "diagnostic-storage".to_owned(),
1099            &json!({
1100                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1101                    "columns": [{"name": "id", "type": "string", "nullable": false},
1102                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1103            }),
1104            ClientLimits {
1105                blob_cache_max_bytes: Some(5),
1106                ..Default::default()
1107            },
1108        )
1109        .unwrap();
1110        let empty = client.diagnostics_storage();
1111        assert_eq!(empty.status, "healthy");
1112        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1113        assert_eq!(empty.retained_outcome_entries, Some(0));
1114        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1115        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1116        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1117            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1118                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1119            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1120                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1121        let written = client.diagnostics_storage();
1122        assert_eq!(written.status, "pressure");
1123        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1124        assert_eq!(written.retained_outcome_entries, Some(2));
1125        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1126        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1127        assert_eq!(
1128            written.pressure_reason_code.as_deref(),
1129            Some("client.blob_cache_over_limit")
1130        );
1131        client
1132            .conn
1133            .execute_batch(
1134                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1135            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1136            )
1137            .unwrap();
1138        let staged = client.diagnostics_storage();
1139        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1140        assert_eq!(staged.retained_outcome_entries, Some(0));
1141        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1142        assert_eq!(staged.status, "healthy");
1143        client.conn.execute_batch("ROLLBACK").unwrap();
1144        assert_eq!(client.diagnostics_storage(), written);
1145        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1146        let unreadable = client.diagnostics_storage();
1147        assert_eq!(unreadable.status, "unreadable");
1148        assert!(unreadable.database_bytes_approx.is_none());
1149        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1150        assert!(unreadable.retained_outcome_entries.is_none());
1151        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1152        assert!(unreadable.blob_cache_bytes_approx.is_none());
1153        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1154        let restored = client.diagnostics_storage();
1155        assert_eq!(restored.status, written.status);
1156        assert_eq!(
1157            restored.retained_outcome_bytes_approx,
1158            written.retained_outcome_bytes_approx
1159        );
1160        client.conn.set_prepared_statement_cache_capacity(0);
1161        assert_eq!(client.diagnostics_storage(), restored);
1162    }
1163
1164    /// Answers every round with one server `ERROR` frame (§1.6).
1165    struct ServerErrorTransport {
1166        code: &'static str,
1167        retryable: bool,
1168    }
1169
1170    impl Transport for ServerErrorTransport {
1171        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1172            Ok(encode_message(&Message {
1173                wire_version: WIRE_VERSION,
1174                msg_kind: MsgKind::Response,
1175                frames: vec![
1176                    Frame::RespHeader {
1177                        required_schema_version: None,
1178                        latest_schema_version: None,
1179                        log_epoch: Some("epoch-1".to_owned()),
1180                        reset_required: Some(false),
1181                    },
1182                    Frame::Error {
1183                        code: self.code.to_owned(),
1184                        message: "server error".to_owned(),
1185                        category: "internal".to_owned(),
1186                        retryable: self.retryable,
1187                        recommended_action: "retryLater".to_owned(),
1188                        details: None,
1189                    },
1190                ],
1191            }))
1192        }
1193
1194        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1195            self.sync(request)
1196        }
1197
1198        fn download_segment(
1199            &mut self,
1200            _request: &SegmentRequest,
1201            _on_progress: &mut dyn FnMut(u64),
1202        ) -> Result<Vec<u8>, TransportError> {
1203            Err(TransportError::new("sync.transport_failed", "offline"))
1204        }
1205
1206        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1207            Ok(())
1208        }
1209
1210        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1211            Ok(())
1212        }
1213
1214        fn realtime_close(&mut self) -> Result<(), TransportError> {
1215            Ok(())
1216        }
1217    }
1218
1219    #[test]
1220    fn retryable_server_error_schedules_a_background_retry() {
1221        let mut client = client();
1222        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1223        let mut transport = ServerErrorTransport {
1224            code: "sync.internal_error",
1225            retryable: true,
1226        };
1227        assert!(matches!(
1228            client.sync(&mut transport),
1229            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1230        ));
1231        assert!(matches!(
1232            client.drain_sync_intents().as_slice(),
1233            [SyncIntent::Background { delay_ms: 250 }]
1234        ));
1235        assert_eq!(
1236            client.progress().snapshot().unwrap().retry_delay_ms,
1237            Some(250)
1238        );
1239        let mut transport = ServerErrorTransport {
1240            code: "sync.invalid_request",
1241            retryable: false,
1242        };
1243        assert!(matches!(
1244            client.sync(&mut transport),
1245            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1246        ));
1247        assert!(client.drain_sync_intents().is_empty());
1248        let progress = client.progress().snapshot().unwrap();
1249        assert_eq!(progress.state, ProgressState::Failed);
1250        assert_eq!(progress.retry_delay_ms, None);
1251    }
1252
1253    #[test]
1254    fn failed_progress_retry_delay_doubles_to_the_cap() {
1255        let mut client = client();
1256        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1257        let mut transport = ServerErrorTransport {
1258            code: "sync.internal_error",
1259            retryable: true,
1260        };
1261        let delays: Vec<Option<u64>> = (0..9)
1262            .map(|_| {
1263                client.sync(&mut transport);
1264                client.progress().snapshot().unwrap().retry_delay_ms
1265            })
1266            .collect();
1267        assert_eq!(
1268            delays,
1269            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1270        );
1271    }
1272
1273    #[test]
1274    fn background_retry_deadlines_back_off_and_reset() {
1275        let mut client = client();
1276        client.schedule_background_retry();
1277        assert!(matches!(
1278            client.drain_sync_intents().as_slice(),
1279            [SyncIntent::Background { delay_ms: 250 }]
1280        ));
1281        client.schedule_background_retry();
1282        assert!(matches!(
1283            client.drain_sync_intents().as_slice(),
1284            [SyncIntent::Background { delay_ms: 500 }]
1285        ));
1286        client.reset_background_retry();
1287        client.schedule_background_retry();
1288        assert!(matches!(
1289            client.drain_sync_intents().as_slice(),
1290            [SyncIntent::Background { delay_ms: 250 }]
1291        ));
1292    }
1293
1294    #[test]
1295    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1296        let mut client = client();
1297        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1298        client
1299            .subscribe(
1300                "epoch-tasks".to_owned(),
1301                "tasks".to_owned(),
1302                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1303                None,
1304            )
1305            .expect("subscribe");
1306        client.drain_sync_intents();
1307
1308        client
1309            .run_log_epoch_reset("epoch-2")
1310            .expect("reset partition log epoch");
1311
1312        assert!(matches!(
1313            client.drain_sync_intents().as_slice(),
1314            [SyncIntent::Interactive]
1315        ));
1316    }
1317
1318    #[derive(Default)]
1319    struct CountingRealtimeTransport {
1320        connects: usize,
1321        closes: usize,
1322        messages: Vec<String>,
1323        blob_uploads: Vec<(String, Vec<u8>)>,
1324        blob_download: Option<Vec<u8>>,
1325        sync_calls: usize,
1326        realtime_sync_calls: usize,
1327        refuse_connect: bool,
1328    }
1329
1330    impl Transport for CountingRealtimeTransport {
1331        fn blob_upload(
1332            &mut self,
1333            blob_id: &str,
1334            bytes: &[u8],
1335            _media_type: Option<&str>,
1336        ) -> Result<(), TransportError> {
1337            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1338            Ok(())
1339        }
1340
1341        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1342            self.blob_download
1343                .clone()
1344                .map(BlobDownload::Bytes)
1345                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1346        }
1347
1348        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1349            self.sync_calls += 1;
1350            Err(TransportError::new("sync.transport_failed", "offline"))
1351        }
1352
1353        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1354            self.realtime_sync_calls += 1;
1355            Err(TransportError::new("sync.transport_failed", "offline"))
1356        }
1357
1358        fn download_segment(
1359            &mut self,
1360            _request: &SegmentRequest,
1361            _on_progress: &mut dyn FnMut(u64),
1362        ) -> Result<Vec<u8>, TransportError> {
1363            Err(TransportError::new("sync.transport_failed", "offline"))
1364        }
1365
1366        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1367            self.connects += 1;
1368            if self.refuse_connect {
1369                return Err(TransportError::new("transport.failed", "refused"));
1370            }
1371            Ok(())
1372        }
1373
1374        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1375            self.messages.push(text.to_owned());
1376            Ok(())
1377        }
1378
1379        fn realtime_close(&mut self) -> Result<(), TransportError> {
1380            self.closes += 1;
1381            Ok(())
1382        }
1383    }
1384
1385    #[test]
1386    fn blob_results_own_downloaded_bytes() {
1387        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1388            {"name":"id","type":"string","nullable":false},
1389            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1390        let bytes = b"owned typed result".to_vec();
1391        let blob_id = blob_id_for(&bytes);
1392        let mut transport = CountingRealtimeTransport {
1393            blob_download: Some(bytes.clone()),
1394            ..Default::default()
1395        };
1396        let result = {
1397            let mut client =
1398                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1399                    .unwrap();
1400            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1401            assert_eq!(result.blob_id, blob_id);
1402            assert_eq!(result.byte_length, bytes.len() as i64);
1403            assert_eq!(result.bytes, bytes);
1404            assert_eq!(result.media_type, None);
1405            assert_eq!(
1406                client
1407                    .conn
1408                    .query_row(
1409                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1410                        rusqlite::params![blob_id],
1411                        |row| row.get::<_, i64>(0),
1412                    )
1413                    .unwrap(),
1414                0
1415            );
1416            result
1417        };
1418        assert_eq!(result.bytes, bytes);
1419
1420        let mut client =
1421            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1422        assert_eq!(
1423            client
1424                .fetch_blob_bytes(&mut transport, "not json")
1425                .unwrap_err(),
1426            (
1427                "client.failed".to_owned(),
1428                "blob ref is not JSON".to_owned()
1429            )
1430        );
1431    }
1432
1433    #[test]
1434    fn visible_blob_references_survive_cache_trimming() {
1435        let schema = json!({"version":1,"tables":[
1436            {"name":"attachments","primaryKey":"id","columns":[
1437                {"name":"id","type":"string","nullable":false},
1438                {"name":"body","type":"blob_ref","nullable":true},
1439                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1440            {"name":"avatars","primaryKey":"id","columns":[
1441                {"name":"id","type":"string","nullable":false},
1442                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1443        ]});
1444        let bytes = b"referenced above cap".to_vec();
1445        let blob_id = blob_id_for(&bytes);
1446        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1447        let mut client = SyncClient::new(
1448            "visible-blob-refs".to_owned(),
1449            &schema,
1450            ClientLimits {
1451                blob_cache_max_bytes: Some(1),
1452                ..Default::default()
1453            },
1454        )
1455        .unwrap();
1456        client.conn.execute(
1457            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1458            rusqlite::params![reference, reference, reference, reference],
1459        ).unwrap();
1460        client
1461            .conn
1462            .execute(
1463                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1464                rusqlite::params![reference],
1465            )
1466            .unwrap();
1467        client.conn.execute(
1468            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1469            [],
1470        ).unwrap();
1471        let mut transport = CountingRealtimeTransport {
1472            blob_download: Some(bytes.clone()),
1473            ..Default::default()
1474        };
1475
1476        assert_eq!(
1477            client
1478                .fetch_blob_bytes(&mut transport, &blob_id)
1479                .unwrap()
1480                .bytes,
1481            bytes
1482        );
1483        let rows = client
1484            .conn
1485            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1486            .unwrap()
1487            .query_map([], |row| row.get::<_, String>(0))
1488            .unwrap()
1489            .collect::<Result<Vec<_>, _>>()
1490            .unwrap();
1491        assert_eq!(rows, vec![blob_id]);
1492    }
1493
1494    #[test]
1495    fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
1496        let mut transport = CountingRealtimeTransport::default();
1497        let path = std::env::temp_dir().join(format!(
1498            "syncular-transport-gate-{}.db",
1499            uuid::Uuid::new_v4()
1500        ));
1501        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1502            {"name":"id","type":"string","nullable":false},
1503            {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
1504        let mut client = SyncClient::open_path(
1505            "gate".into(),
1506            &schema,
1507            ClientLimits::default(),
1508            path.to_str().unwrap(),
1509        )
1510        .unwrap();
1511        client.connect_realtime(&mut transport).unwrap();
1512        client.set_transport_enabled(&mut transport, false);
1513        assert_eq!(transport.closes, 1);
1514        client.begin_security_preflight();
1515        client.activate_security(Default::default()).unwrap();
1516        client
1517            .subscribe(
1518                "tasks".into(),
1519                "tasks".into(),
1520                vec![("project_id".into(), vec!["p1".into()])],
1521                None,
1522            )
1523            .unwrap();
1524        for id in ["one", "two"] {
1525            client
1526                .mutate(vec![Mutation::Upsert {
1527                    table: "tasks".into(),
1528                    values: Map::from_iter([
1529                        ("id".into(), json!(id)),
1530                        ("project_id".into(), json!("p1")),
1531                    ]),
1532                    base_version: None,
1533                }])
1534                .unwrap();
1535        }
1536        assert_eq!(
1537            client
1538                .query("SELECT id FROM tasks ORDER BY id", &[])
1539                .unwrap()
1540                .len(),
1541            2
1542        );
1543        assert!(
1544            matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
1545        );
1546        assert!(client
1547            .connect_realtime(&mut transport)
1548            .unwrap_err()
1549            .starts_with("sync.offline:"));
1550        assert!(client
1551            .set_presence(&mut transport, "project:p1", None)
1552            .unwrap_err()
1553            .starts_with("sync.offline:"));
1554        assert_eq!(transport.sync_calls, 0);
1555        assert_eq!(transport.realtime_sync_calls, 0);
1556        assert_eq!(transport.connects, 1);
1557        assert!(transport.messages.is_empty());
1558        assert!(matches!(
1559            client.drain_sync_intents().as_slice(),
1560            [SyncIntent::None]
1561        ));
1562        let pending = client.pending_commit_ids();
1563        drop(client);
1564        let mut reopened = SyncClient::open_path(
1565            "gate".into(),
1566            &schema,
1567            ClientLimits::default(),
1568            path.to_str().unwrap(),
1569        )
1570        .unwrap();
1571        assert!(reopened.transport_enabled());
1572        reopened.set_transport_enabled(&mut transport, false);
1573        assert_eq!(reopened.pending_commit_ids(), pending);
1574        assert_eq!(
1575            reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
1576            2
1577        );
1578        reopened.set_transport_enabled(&mut transport, true);
1579        assert!(matches!(
1580            reopened.drain_sync_intents().as_slice(),
1581            [SyncIntent::Interactive]
1582        ));
1583        reopened.set_transport_enabled(&mut transport, true);
1584        assert!(reopened.drain_sync_intents().is_empty());
1585        drop(reopened);
1586        std::fs::remove_file(path).unwrap();
1587    }
1588
1589    #[test]
1590    fn realtime_connection_ownership_is_idempotent() {
1591        let mut client = client();
1592        let mut transport = CountingRealtimeTransport {
1593            connects: 0,
1594            closes: 0,
1595            ..Default::default()
1596        };
1597        client
1598            .connect_realtime(&mut transport)
1599            .expect("first connect");
1600        client
1601            .connect_realtime(&mut transport)
1602            .expect("idempotent connect");
1603        assert_eq!(transport.connects, 1);
1604        client.disconnect_realtime(&mut transport);
1605        client.disconnect_realtime(&mut transport);
1606        assert_eq!(transport.closes, 1);
1607        client
1608            .connect_realtime(&mut transport)
1609            .expect("deliberate reconnect");
1610        assert_eq!(transport.connects, 2);
1611    }
1612
1613    #[test]
1614    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1615        let mut client = client();
1616        client
1617            .set_realtime_policy(RealtimePolicy::Required)
1618            .expect("set policy");
1619        let mut transport = CountingRealtimeTransport::default();
1620        match client.sync(&mut transport) {
1621            SyncOutcome::RealtimeUnavailable {
1622                state,
1623                reason_code,
1624                retry_delay_ms,
1625            } => {
1626                assert_eq!(state, RealtimeState::Disconnected);
1627                assert_eq!(reason_code, None);
1628                assert_eq!(retry_delay_ms, 250);
1629            }
1630            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1631        }
1632        assert_eq!(transport.sync_calls, 0);
1633        assert!(matches!(
1634            client.drain_sync_intents().as_slice(),
1635            [SyncIntent::Background { delay_ms: 250 }]
1636        ));
1637        let snapshot = client
1638            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1639            .expect("diagnostics");
1640        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1641        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1642        assert_eq!(snapshot.host.realtime_reason_code, None);
1643        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1644        assert_eq!(
1645            snapshot
1646                .last_round
1647                .as_ref()
1648                .and_then(|round| round.error_code.clone()),
1649            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1650        );
1651    }
1652
1653    #[test]
1654    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1655        let mut client = client();
1656        client
1657            .set_realtime_policy(RealtimePolicy::Required)
1658            .expect("set policy");
1659        let mut transport = CountingRealtimeTransport {
1660            refuse_connect: true,
1661            ..Default::default()
1662        };
1663        assert!(client.connect_realtime(&mut transport).is_err());
1664        assert_eq!(client.realtime_state(), RealtimeState::Refused);
1665        let snapshot = client
1666            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1667            .expect("diagnostics");
1668        assert_eq!(
1669            snapshot.host.realtime_reason_code.as_deref(),
1670            Some("transport.failed")
1671        );
1672        match client.sync(&mut transport) {
1673            SyncOutcome::RealtimeUnavailable {
1674                state, reason_code, ..
1675            } => {
1676                assert_eq!(state, RealtimeState::Refused);
1677                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1678            }
1679            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1680        }
1681        assert_eq!(transport.sync_calls, 0);
1682        assert_eq!(transport.realtime_sync_calls, 0);
1683    }
1684
1685    #[test]
1686    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1687        let mut client = client();
1688        client
1689            .set_realtime_policy(RealtimePolicy::Required)
1690            .expect("set policy");
1691        let mut transport = CountingRealtimeTransport::default();
1692        client.connect_realtime(&mut transport).expect("connect");
1693        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1694        assert!(
1695            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1696            "a policy change cannot silently re-route a connected socket"
1697        );
1698        assert!(matches!(
1699            client.sync(&mut transport),
1700            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1701        ));
1702        assert_eq!(client.realtime_state(), RealtimeState::Lost);
1703        assert_eq!(transport.realtime_sync_calls, 1);
1704        assert_eq!(transport.sync_calls, 0);
1705        let snapshot = client
1706            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1707            .expect("diagnostics");
1708        assert_eq!(
1709            snapshot.host.realtime_reason_code.as_deref(),
1710            Some("sync.transport_failed")
1711        );
1712        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1713        match client.sync(&mut transport) {
1714            SyncOutcome::RealtimeUnavailable {
1715                state, reason_code, ..
1716            } => {
1717                assert_eq!(state, RealtimeState::Lost);
1718                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1719            }
1720            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1721        }
1722        assert_eq!(transport.sync_calls, 0);
1723        client.disconnect_realtime(&mut transport);
1724        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1725        assert_eq!(transport.closes, 1);
1726        client.connect_realtime(&mut transport).expect("reconnect");
1727        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1728        assert_eq!(transport.connects, 2);
1729    }
1730
1731    #[test]
1732    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1733        let mut client = client();
1734        client
1735            .set_realtime_policy(RealtimePolicy::Off)
1736            .expect("set policy");
1737        let mut transport = CountingRealtimeTransport::default();
1738        assert!(client.connect_realtime(&mut transport).is_err());
1739        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1740        assert!(matches!(
1741            client.sync(&mut transport),
1742            SyncOutcome::Failed { .. }
1743        ));
1744        assert_eq!(transport.sync_calls, 1);
1745        assert_eq!(transport.realtime_sync_calls, 0);
1746        let snapshot = client
1747            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1748            .expect("diagnostics");
1749        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1750        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1751    }
1752
1753    #[test]
1754    fn optional_policy_keeps_the_http_round() {
1755        let mut client = client();
1756        let mut transport = CountingRealtimeTransport::default();
1757        assert!(matches!(
1758            client.sync(&mut transport),
1759            SyncOutcome::Failed { .. }
1760        ));
1761        assert_eq!(transport.sync_calls, 1);
1762        assert_eq!(transport.realtime_sync_calls, 0);
1763        let snapshot = client
1764            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1765            .expect("diagnostics");
1766        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1767        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1768    }
1769
1770    #[test]
1771    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1772        let mut client = client();
1773        client
1774            .subscribe(
1775                "repair-tasks".to_owned(),
1776                "tasks".to_owned(),
1777                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1778                None,
1779            )
1780            .expect("subscribe");
1781        {
1782            let sub = client
1783                .subs
1784                .iter_mut()
1785                .find(|sub| sub.id == "repair-tasks")
1786                .expect("subscription");
1787            sub.cursor = 42;
1788            sub.synced_once = true;
1789            let persisted = sub.clone();
1790            client.persist_sub(&persisted).unwrap();
1791        }
1792        for table in ["_syncular_base_tasks", "tasks"] {
1793            client
1794                .conn
1795                .execute(
1796                    &format!(
1797                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1798                    ),
1799                    rusqlite::params!["server-row", "p1"],
1800                )
1801                .expect("seed server row");
1802        }
1803        let pending = client
1804            .mutate(vec![Mutation::Upsert {
1805                table: "tasks".to_owned(),
1806                values: Map::from_iter([
1807                    ("id".to_owned(), Value::from("offline-row")),
1808                    ("project_id".to_owned(), Value::from("p1")),
1809                ]),
1810                base_version: None,
1811            }])
1812            .expect("queue offline work");
1813        client.drain_change_batches();
1814        client.drain_sync_intents();
1815
1816        assert_eq!(
1817            client
1818                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1819                    rebootstrap_id: "support-case-001".to_owned(),
1820                })
1821                .expect("rebootstrap"),
1822            LocalDataRebootstrapResult {
1823                already_applied: false,
1824                retained_commits: 1,
1825                reset_subscriptions: 1,
1826            }
1827        );
1828        let visible_ids = client
1829            .conn
1830            .prepare("SELECT id FROM tasks ORDER BY id")
1831            .expect("prepare visible ids")
1832            .query_map([], |row| row.get::<_, String>(0))
1833            .expect("query visible ids")
1834            .collect::<Result<Vec<_>, _>>()
1835            .expect("collect visible ids");
1836        assert_eq!(visible_ids, vec!["offline-row"]);
1837        assert_eq!(client.pending_commit_ids(), vec![pending]);
1838        assert_eq!(
1839            client
1840                .subscription_state("repair-tasks")
1841                .expect("subscription")
1842                .cursor,
1843            -1
1844        );
1845        assert!(client.upgrading());
1846        assert!(client.sync_needed());
1847        assert!(matches!(
1848            client.drain_sync_intents().as_slice(),
1849            [SyncIntent::Interactive]
1850        ));
1851        assert_eq!(client.drain_change_batches().len(), 1);
1852
1853        assert_eq!(
1854            client
1855                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1856                    rebootstrap_id: "support-case-001".to_owned(),
1857                })
1858                .expect("idempotent retry"),
1859            LocalDataRebootstrapResult {
1860                already_applied: true,
1861                retained_commits: 1,
1862                reset_subscriptions: 1,
1863            }
1864        );
1865        assert!(client.drain_change_batches().is_empty());
1866    }
1867
1868    #[test]
1869    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1870        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1871        assert_eq!(
1872            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1873            (3, 4)
1874        );
1875        assert_eq!(
1876            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1877            (0, 0)
1878        );
1879        for malformed in [
1880            "",
1881            "{}",
1882            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1883            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1884            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1885        ] {
1886            assert_eq!(
1887                decode_local_rebootstrap_receipt(malformed)
1888                    .expect_err("malformed receipt must fail"),
1889                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1890            );
1891        }
1892    }
1893
1894    #[test]
1895    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1896        let path = std::env::temp_dir().join(format!(
1897            "syncular-rebootstrap-receipt-{}.db",
1898            uuid::Uuid::new_v4()
1899        ));
1900        let schema = json!({
1901            "version": 1,
1902            "tables": [{
1903                "name": "tasks",
1904                "primaryKey": "id",
1905                "columns": [
1906                    { "name": "id", "type": "string", "nullable": false },
1907                    { "name": "project_id", "type": "string", "nullable": false }
1908                ],
1909                "scopes": [{ "pattern": "project:{project_id}" }]
1910            }]
1911        });
1912        let path_string = path.to_str().expect("UTF-8 temp path");
1913
1914        {
1915            let mut first = SyncClient::open_path(
1916                "repair-restart-client".to_owned(),
1917                &schema,
1918                ClientLimits::default(),
1919                path_string,
1920            )
1921            .expect("first open");
1922            first
1923                .subscribe(
1924                    "repair-tasks".to_owned(),
1925                    "tasks".to_owned(),
1926                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1927                    None,
1928                )
1929                .expect("subscribe");
1930            first
1931                .mutate(vec![Mutation::Upsert {
1932                    table: "tasks".to_owned(),
1933                    values: Map::from_iter([
1934                        ("id".to_owned(), Value::from("offline-row")),
1935                        ("project_id".to_owned(), Value::from("p1")),
1936                    ]),
1937                    base_version: None,
1938                }])
1939                .expect("queue offline work");
1940            assert_eq!(
1941                first
1942                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1943                        rebootstrap_id: "restart-receipt".to_owned(),
1944                    })
1945                    .expect("first rebootstrap"),
1946                LocalDataRebootstrapResult {
1947                    already_applied: false,
1948                    retained_commits: 1,
1949                    reset_subscriptions: 1,
1950                }
1951            );
1952        }
1953
1954        let mut reopened = SyncClient::open_path(
1955            "repair-restart-client".to_owned(),
1956            &schema,
1957            ClientLimits::default(),
1958            path_string,
1959        )
1960        .expect("reopen");
1961        reopened.drain_change_batches();
1962        reopened.drain_sync_intents();
1963        assert_eq!(
1964            reopened
1965                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1966                    rebootstrap_id: "restart-receipt".to_owned(),
1967                })
1968                .expect("receipt replay"),
1969            LocalDataRebootstrapResult {
1970                already_applied: true,
1971                retained_commits: 1,
1972                reset_subscriptions: 1,
1973            }
1974        );
1975        assert!(reopened.drain_change_batches().is_empty());
1976        assert!(reopened.drain_sync_intents().is_empty());
1977        drop(reopened);
1978        std::fs::remove_file(path).expect("remove temp database");
1979    }
1980
1981    #[test]
1982    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1983        let mut malformed = client();
1984        malformed
1985            .subscribe(
1986                "repair-tasks".to_owned(),
1987                "tasks".to_owned(),
1988                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1989                None,
1990            )
1991            .expect("subscribe");
1992        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1993        malformed.drain_change_batches();
1994        malformed.drain_sync_intents();
1995        let malformed_error = malformed
1996            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1997                rebootstrap_id: "malformed".to_owned(),
1998            })
1999            .expect_err("malformed receipt must fail closed");
2000        assert_eq!(
2001            malformed_error,
2002            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2003        );
2004        assert!(!malformed.upgrading());
2005        assert_eq!(
2006            malformed
2007                .subscription_state("repair-tasks")
2008                .expect("unchanged subscription")
2009                .cursor,
2010            -1
2011        );
2012        assert!(malformed.drain_change_batches().is_empty());
2013        assert!(malformed.drain_sync_intents().is_empty());
2014
2015        let mut unreadable = client();
2016        unreadable
2017            .conn
2018            .execute(
2019                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
2020                rusqlite::params!["server-row", "p1"],
2021            )
2022            .expect("seed visible row");
2023        unreadable
2024            .conn
2025            .execute("DROP TABLE _syncular_meta", [])
2026            .expect("break marker storage");
2027        let unreadable_error = unreadable
2028            .rebootstrap_local_data(&LocalDataRebootstrapInput {
2029                rebootstrap_id: "unreadable".to_owned(),
2030            })
2031            .expect_err("unreadable marker storage must fail closed");
2032        assert_eq!(
2033            unreadable_error,
2034            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
2035        );
2036        let visible_rows = unreadable
2037            .conn
2038            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
2039            .expect("visible projection remains");
2040        assert_eq!(visible_rows, 1);
2041        assert!(!unreadable.upgrading());
2042        assert!(unreadable.drain_change_batches().is_empty());
2043        assert!(unreadable.drain_sync_intents().is_empty());
2044    }
2045
2046    #[test]
2047    fn local_rebootstrap_cannot_bypass_schema_floor() {
2048        let mut client = client();
2049        client.set_schema_floor(Some(SchemaFloor {
2050            required_schema_version: Some(2),
2051            latest_schema_version: Some(2),
2052        }));
2053        let error = client
2054            .rebootstrap_local_data(&LocalDataRebootstrapInput {
2055                rebootstrap_id: "blocked-floor".to_owned(),
2056            })
2057            .expect_err("schema floor must block repair");
2058        assert!(error.contains("cannot bypass an active schema-floor stop"));
2059    }
2060
2061    #[test]
2062    fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
2063        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2064            "columns":[{"name":"id","type":"string","nullable":false},
2065                {"name":"project_id","type":"string","nullable":false},
2066                {"name":"starts_at_ms","type":"integer","nullable":false},
2067                {"name":"version","type":"integer","nullable":false}],
2068            "scopes":[{"pattern":"project:{project_id}"}]}]});
2069        let mut client =
2070            SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
2071        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2072        client
2073            .write_base_row(
2074                "tasks",
2075                &vec![
2076                    Some(ColumnValue::String("t1".into())),
2077                    Some(ColumnValue::String("p1".into())),
2078                    Some(ColumnValue::Integer(100)),
2079                    Some(ColumnValue::Integer(1)),
2080                ],
2081                1,
2082            )
2083            .unwrap();
2084        client.rebuild_overlay();
2085        let first = client
2086            .patch(
2087                "tasks",
2088                "t1",
2089                Map::from_iter([
2090                    ("starts_at_ms".into(), json!(200)),
2091                    ("version".into(), json!(2)),
2092                ]),
2093                None,
2094            )
2095            .unwrap();
2096        let (_, first_request) = client.build_request(false);
2097        // This second edit happens while the first request is in flight.
2098        let second = client
2099            .patch(
2100                "tasks",
2101                "t1",
2102                Map::from_iter([
2103                    ("starts_at_ms".into(), json!(300)),
2104                    ("version".into(), json!(3)),
2105                ]),
2106                None,
2107            )
2108            .unwrap();
2109        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2110        for (id, seq, meta) in [
2111            (first, 2, first_request),
2112            (second, 3, client.build_request(false).1),
2113        ] {
2114            let response = Message {
2115                wire_version: WIRE_VERSION,
2116                msg_kind: MsgKind::Response,
2117                frames: vec![
2118                    Frame::RespHeader {
2119                        required_schema_version: None,
2120                        latest_schema_version: None,
2121                        log_epoch: Some("epoch-1".into()),
2122                        reset_required: Some(false),
2123                    },
2124                    Frame::PushResult {
2125                        client_commit_id: id,
2126                        status: PushStatus::Applied,
2127                        commit_seq: Some(seq),
2128                        results: vec![OpResult::Applied { op_index: 0 }],
2129                    },
2130                ],
2131            };
2132            assert!(matches!(
2133                client.process_response(&mut transport, response, &meta),
2134                SyncOutcome::Ok(_)
2135            ));
2136            let row = &client
2137                .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
2138                .unwrap()[0];
2139            assert_eq!(
2140                row["starts_at_ms"],
2141                json!(300),
2142                "an applied ACK without a pull image must not expose the old base"
2143            );
2144            assert_eq!(
2145                row["version"],
2146                json!(3),
2147                "the queued domain version must remain monotonic"
2148            );
2149        }
2150    }
2151
2152    #[test]
2153    fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
2154        for stored in [None, Some("epoch-old")] {
2155            let mut client = client();
2156            client
2157                .subscribe(
2158                    "tasks".into(),
2159                    "tasks".into(),
2160                    vec![("project_id".into(), vec!["p1".into()])],
2161                    None,
2162                )
2163                .unwrap();
2164            client
2165                .conn
2166                .execute_batch(
2167                    "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
2168                )
2169                .unwrap();
2170            let id = client
2171                .mutate(vec![Mutation::Upsert {
2172                    table: "tasks".into(),
2173                    values: Map::from_iter([
2174                        ("id".into(), json!("offline")),
2175                        ("project_id".into(), json!("p1")),
2176                    ]),
2177                    base_version: None,
2178                }])
2179                .unwrap();
2180            if let Some(epoch) = stored {
2181                client.set_meta(LOG_EPOCH_KEY, epoch);
2182            }
2183            let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
2184            let (_, meta) = client.build_request(false);
2185            client.drain_change_batches();
2186            client.drain_sync_intents();
2187            let response = Message {
2188                wire_version: WIRE_VERSION,
2189                msg_kind: MsgKind::Response,
2190                frames: vec![Frame::RespHeader {
2191                    required_schema_version: None,
2192                    latest_schema_version: None,
2193                    log_epoch: Some("epoch-new".into()),
2194                    reset_required: Some(true),
2195                }],
2196            };
2197            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2198            let outcome = client.process_response(&mut transport, response, &meta);
2199            let SyncOutcome::Ok(report) = outcome else {
2200                panic!("epoch round failed: {outcome:?}");
2201            };
2202            assert_eq!(
2203                report.resets,
2204                if stored.is_none() {
2205                    Vec::<String>::new()
2206                } else {
2207                    vec!["tasks".into()]
2208                }
2209            );
2210            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
2211            assert_eq!(client.pending_commit_ids(), vec![id]);
2212            assert_eq!(
2213                client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2214                json!("offline")
2215            );
2216            let trigger: i64 = client
2217                .conn
2218                .query_row(
2219                    "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
2220                    [],
2221                    |row| row.get(0),
2222                )
2223                .unwrap();
2224            if stored.is_none() {
2225                assert!(!client.upgrading);
2226                assert!(client
2227                    .drain_change_batches()
2228                    .iter()
2229                    .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
2230                assert_eq!(
2231                    serde_json::to_value(client.subscription_state("tasks")).unwrap(),
2232                    before
2233                );
2234                assert_eq!(trigger, 1, "acquisition must not drop the synced table");
2235            } else {
2236                assert!(client.upgrading);
2237                assert_eq!(trigger, 0, "an epoch change must drop the old table");
2238            }
2239            assert!(matches!(
2240                client.drain_sync_intents().as_slice(),
2241                [SyncIntent::Interactive]
2242            ));
2243        }
2244    }
2245
2246    #[test]
2247    fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
2248        for key in ["logEpoch", "localRevision"] {
2249            let mut client = client();
2250            let id = client
2251                .mutate(vec![Mutation::Upsert {
2252                    table: "tasks".into(),
2253                    values: Map::from_iter([
2254                        ("id".into(), json!("offline")),
2255                        ("project_id".into(), json!("p1")),
2256                    ]),
2257                    base_version: None,
2258                }])
2259                .unwrap();
2260            let revision = client.local_revision();
2261            let needed = client.sync_needed;
2262            client.drain_change_batches();
2263            client.drain_sync_intents();
2264            client.conn.execute_batch(&format!("CREATE TRIGGER fail_acquisition BEFORE INSERT ON _syncular_meta WHEN NEW.key='{key}' BEGIN SELECT RAISE(FAIL,'injected acquisition failure'); END")).unwrap();
2265            assert!(client.run_log_epoch_reset("epoch-new").is_err());
2266            assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
2267            assert!(!client.upgrading);
2268            assert_eq!(client.sync_needed, needed);
2269            assert_eq!(client.local_revision(), revision);
2270            assert_eq!(client.pending_commit_ids(), [id]);
2271            assert!(client.drain_change_batches().is_empty());
2272            assert!(client.drain_sync_intents().is_empty());
2273        }
2274    }
2275
2276    #[test]
2277    fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
2278        for subscriptions in [0, 2, 6, 12] {
2279            let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
2280                { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
2281                { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
2282            ] }), ClientLimits::default()).unwrap();
2283            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2284            client.conn.execute_batch("CREATE TABLE ack_writes(kind TEXT); CREATE TRIGGER count_ack_restore AFTER DELETE ON tasks BEGIN INSERT INTO ack_writes VALUES('restore'); END; CREATE TRIGGER count_ack_replay AFTER INSERT ON tasks BEGIN INSERT INTO ack_writes VALUES('replay'); END;").unwrap();
2285            for index in 0..32 {
2286                client
2287                    .mutate(vec![Mutation::Upsert {
2288                        table: "tasks".into(),
2289                        values: Map::from_iter([
2290                            ("id".into(), json!(format!("task-{index}"))),
2291                            ("project_id".into(), json!("p1")),
2292                        ]),
2293                        base_version: None,
2294                    }])
2295                    .unwrap();
2296            }
2297            let (_, meta) = client.build_request(false);
2298            let mut frames = vec![Frame::RespHeader {
2299                required_schema_version: None,
2300                latest_schema_version: None,
2301                log_epoch: Some("epoch-1".into()),
2302                reset_required: Some(false),
2303            }];
2304            frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
2305                Frame::PushResult {
2306                    client_commit_id: id.clone(),
2307                    status: PushStatus::Applied,
2308                    commit_seq: Some(index as i64 + 1),
2309                    results: vec![OpResult::Applied { op_index: 0 }],
2310                }
2311            }));
2312            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2313            client.acknowledged_replay_count.set(0);
2314            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2315            assert!(matches!(
2316                client.process_response(
2317                    &mut transport,
2318                    Message {
2319                        wire_version: WIRE_VERSION,
2320                        msg_kind: MsgKind::Response,
2321                        frames
2322                    },
2323                    &meta
2324                ),
2325                SyncOutcome::Ok(_)
2326            ));
2327            assert_eq!(client.acknowledged_replay_count.get(), 32);
2328            assert_eq!(
2329                client
2330                    .query(
2331                        "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
2332                        &[]
2333                    )
2334                    .unwrap()[0]["n"],
2335                json!(32)
2336            );
2337            assert_eq!(
2338                client
2339                    .query(
2340                        "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
2341                        &[]
2342                    )
2343                    .unwrap()[0]["n"],
2344                json!(32)
2345            );
2346            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2347            client.acknowledged_replay_count.set(0);
2348            for index in 0..subscriptions {
2349                client
2350                    .subscribe(
2351                        format!("docs-{index}"),
2352                        "docs".into(),
2353                        vec![("project_id".into(), vec![format!("scope-{index}")])],
2354                        None,
2355                    )
2356                    .unwrap();
2357                let sub = client.subs.len() - 1;
2358                client.subs[sub].effective =
2359                    Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
2360                let segment = RowsSegment {
2361                    table: "docs".into(),
2362                    schema_version: 1,
2363                    columns: client.schema.table("docs").unwrap().wire_columns.clone(),
2364                    blocks: if index % 2 == 0 {
2365                        vec![vec![ssp2::segment::SegmentRow {
2366                            values: vec![
2367                                Some(ColumnValue::String(format!("doc-{index}"))),
2368                                Some(ColumnValue::String(format!("scope-{index}"))),
2369                            ],
2370                            server_version: 1,
2371                        }]]
2372                    } else {
2373                        vec![]
2374                    },
2375                };
2376                assert!(client.apply_segment(sub, &segment, true).is_ok());
2377            }
2378            assert_eq!(
2379                client
2380                    .query("SELECT count(*) AS n FROM ack_writes", &[])
2381                    .unwrap()[0]["n"],
2382                json!(0),
2383                "unrelated bootstrap must not restore/replay tasks"
2384            );
2385            assert_eq!(
2386                client.acknowledged_replay_count.get(),
2387                0,
2388                "unrelated bootstrap must not decode task ACKs"
2389            );
2390            assert_eq!(
2391                client
2392                    .query("SELECT count(*) AS n FROM tasks", &[])
2393                    .unwrap()[0]["n"],
2394                json!(32)
2395            );
2396            assert_eq!(
2397                client
2398                    .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
2399                    .unwrap()[0]["n"],
2400                json!(32)
2401            );
2402        }
2403    }
2404
2405    #[test]
2406    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
2407        let mut client = client();
2408        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2409        const COMMIT_COUNT: usize = 32;
2410
2411        for index in 0..COMMIT_COUNT {
2412            client
2413                .mutate(vec![Mutation::Upsert {
2414                    table: "tasks".to_owned(),
2415                    values: Map::from_iter([
2416                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2417                        ("project_id".to_owned(), Value::from("project-1")),
2418                    ]),
2419                    base_version: None,
2420                }])
2421                .expect("queue commit");
2422        }
2423
2424        let (_, request_meta) = client.build_request(false);
2425        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
2426        let mut frames = vec![Frame::RespHeader {
2427            required_schema_version: None,
2428            latest_schema_version: None,
2429            log_epoch: Some("epoch-1".to_owned()),
2430            reset_required: Some(false),
2431        }];
2432        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
2433            |(index, client_commit_id)| Frame::PushResult {
2434                client_commit_id: client_commit_id.clone(),
2435                status: PushStatus::Applied,
2436                commit_seq: Some(index as i64 + 1),
2437                results: vec![OpResult::Applied { op_index: 0 }],
2438            },
2439        ));
2440        let response = Message {
2441            wire_version: WIRE_VERSION,
2442            msg_kind: MsgKind::Response,
2443            frames,
2444        };
2445        let mut transport =
2446            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2447
2448        client.overlay_rebuild_count.set(0);
2449        client.outcome_prune_count.set(0);
2450        let outcome = client.process_response(&mut transport, response, &request_meta);
2451        assert!(matches!(outcome, SyncOutcome::Ok(_)));
2452        assert!(client.pending_commit_ids().is_empty());
2453        assert_eq!(
2454            client.overlay_rebuild_count.get(),
2455            1,
2456            "one response must reconcile its acknowledged commits with one overlay rebuild"
2457        );
2458        assert_eq!(
2459            client.outcome_prune_count.get(),
2460            1,
2461            "one response must enforce outcome retention once"
2462        );
2463    }
2464
2465    #[test]
2466    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
2467        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
2468            "columns": [
2469                {"name":"id", "type":"string", "nullable":false},
2470                {"name":"project_id", "type":"string", "nullable":false},
2471                {"name":"title", "type":"string", "nullable":false}
2472            ], "scopes": [{"pattern":"project:{project_id}"}],
2473            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2474            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
2475        }] });
2476        let mut incremental =
2477            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2478        let mut reference =
2479            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2480        let tables = vec!["tasks".to_owned()];
2481        for index in 0..32 {
2482            let id = format!("task-{}", index % 8);
2483            let values = Map::from_iter([
2484                ("id".to_owned(), Value::from(id.clone())),
2485                (
2486                    "project_id".to_owned(),
2487                    Value::from(format!("p{}", index % 2)),
2488                ),
2489                ("title".to_owned(), Value::from(format!("needle{index}"))),
2490            ]);
2491            let payload = encode_row_json(
2492                incremental.schema.table("tasks").unwrap(),
2493                &id,
2494                &values,
2495                &incremental.encryption,
2496            )
2497            .unwrap();
2498            let change = ssp2::model::Change {
2499                table_index: 0,
2500                row_id: id,
2501                op: if index % 5 == 0 {
2502                    Op::Delete
2503                } else {
2504                    Op::Upsert
2505                },
2506                row_version: Some(index + 1),
2507                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
2508                row: Some(payload),
2509            };
2510            reference.overlay_dirty.set(true);
2511            for instance in [&mut incremental, &mut reference] {
2512                instance.begin_observation("mirror_test").unwrap();
2513                let mut batch = ChangeAccumulator::default();
2514                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
2515                instance
2516                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
2517                    .unwrap();
2518                instance.rebuild_overlay_if_dirty();
2519                instance.finish_observation("mirror_test", batch).unwrap();
2520            }
2521            for sql in [
2522                "SELECT * FROM tasks ORDER BY id",
2523                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2524            ] {
2525                assert_eq!(
2526                    incremental.query(sql, &[]).unwrap(),
2527                    reference.query(sql, &[]).unwrap()
2528                );
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.overlay_rebuild_count.get(), 0);
2537        assert_eq!(reference.overlay_rebuild_count.get(), 32);
2538    }
2539
2540    #[test]
2541    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
2542        let cases = [
2543            (
2544                "string",
2545                vec![
2546                    json!("1"),
2547                    json!("01"),
2548                    json!(""),
2549                    json!("nul\u{0}id"),
2550                    json!("é"),
2551                ],
2552            ),
2553            (
2554                "integer",
2555                vec![
2556                    json!(i64::MIN),
2557                    json!(-1),
2558                    json!(0),
2559                    json!(1),
2560                    json!(i64::MAX),
2561                ],
2562            ),
2563            ("boolean", vec![json!(false), json!(true)]),
2564            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2565        ];
2566        for (kind, ids) in cases {
2567            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2568                "columns":[{"name":"id","type":kind,"nullable":false},
2569                    {"name":"project_id","type":"string","nullable":false}],
2570                "scopes":[{"pattern":"project:{project_id}"}]}]});
2571            let mut client =
2572                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2573            for (index, id) in ids.iter().enumerate() {
2574                let table = client.schema.table("tasks").unwrap();
2575                client
2576                    .write_base_row(
2577                        "tasks",
2578                        &vec![
2579                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2580                            Some(ColumnValue::String(format!("p{index}"))),
2581                        ],
2582                        1,
2583                    )
2584                    .unwrap();
2585            }
2586            client.rebuild_overlay_if_dirty();
2587            for base in [true, false] {
2588                let full = if base {
2589                    base_table("tasks")
2590                } else {
2591                    visible_table("tasks")
2592                };
2593                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2594                for row_id in [
2595                    "1",
2596                    "01",
2597                    "1.0",
2598                    "1.0000000000000002",
2599                    "+1",
2600                    "1e0",
2601                    " 1",
2602                    "-1",
2603                    "0",
2604                    "9223372036854775807",
2605                    "-9223372036854775808",
2606                    "true",
2607                    "false",
2608                    "",
2609                    "nul\u{0}id",
2610                    "é",
2611                    "null",
2612                    "{\"a\":1}",
2613                ] {
2614                    let old_sql =
2615                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2616                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2617                    let old = client
2618                        .conn
2619                        .prepare(&old_sql)
2620                        .unwrap()
2621                        .query_map([row_id], |row| row.get::<_, String>(0))
2622                        .unwrap()
2623                        .collect::<Result<Vec<_>, _>>()
2624                        .unwrap();
2625                    let new = client
2626                        .conn
2627                        .prepare_cached(&new_sql)
2628                        .unwrap()
2629                        .query_map([row_id], |row| row.get::<_, String>(0))
2630                        .unwrap()
2631                        .collect::<Result<Vec<_>, _>>()
2632                        .unwrap();
2633                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2634                    let mut batch = ChangeAccumulator::default();
2635                    assert_eq!(
2636                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
2637                        !old.is_empty()
2638                    );
2639                    let expected = old.first().map(|scope| {
2640                        BTreeMap::from([(
2641                            "tasks".to_owned(),
2642                            Some(BTreeSet::from([format!("project:{scope}")])),
2643                        )])
2644                    });
2645                    assert_eq!(batch.tables, expected.unwrap_or_default());
2646                    client
2647                        .conn
2648                        .execute_batch("SAVEPOINT lookup_delete")
2649                        .unwrap();
2650                    if base {
2651                        client.delete_base_row("tasks", row_id).unwrap();
2652                    } else {
2653                        client.apply_outbox_ops(&[OutboxOp {
2654                            upsert: false,
2655                            table: "tasks".into(),
2656                            row_id: row_id.into(),
2657                            base_version: None,
2658                            values: None,
2659                        }]);
2660                    }
2661                    let remaining: i64 = client
2662                        .conn
2663                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2664                            row.get(0)
2665                        })
2666                        .unwrap();
2667                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2668                    client
2669                        .conn
2670                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2671                        .unwrap();
2672                    client.overlay_dirty.set(false);
2673                }
2674                for (predicate, expected) in [
2675                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2676                    (predicate, "SEARCH"),
2677                ] {
2678                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2679                    let details = client
2680                        .conn
2681                        .prepare(&sql)
2682                        .unwrap()
2683                        .query_map(["1"], |row| row.get::<_, String>(3))
2684                        .unwrap()
2685                        .collect::<Result<Vec<_>, _>>()
2686                        .unwrap();
2687                    assert!(
2688                        details.iter().any(|detail| detail.contains(expected)),
2689                        "{kind}: {details:?}"
2690                    );
2691                }
2692            }
2693        }
2694    }
2695
2696    #[test]
2697    fn primary_keys_reject_every_ineligible_column_type() {
2698        // §2.4: a `rowId` is a string, so a primary key type whose string form
2699        // differs per renderer, or cannot be reproduced by local storage
2700        // comparison, is a schema error rather than a silent lookup miss.
2701        for ty in ["float", "bytes", "crdt", "blob_ref"] {
2702            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2703                "columns":[{"name":"id","type":ty,"nullable":false},
2704                    {"name":"project_id","type":"string","nullable":false}],
2705                "scopes":[{"pattern":"project:{project_id}"}]}]});
2706            let error =
2707                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2708                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2709                    Err(error) => error,
2710                };
2711            assert!(
2712                error.contains("has an ineligible column type (§2.4)"),
2713                "{ty}: {error}"
2714            );
2715        }
2716        for ty in ["string", "integer", "boolean", "json"] {
2717            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2718                "columns":[{"name":"id","type":ty,"nullable":false},
2719                    {"name":"project_id","type":"string","nullable":false}],
2720                "scopes":[{"pattern":"project:{project_id}"}]}]});
2721            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2722                .unwrap_or_else(|error| panic!("{ty}: {error}"));
2723        }
2724    }
2725
2726    #[test]
2727    fn pending_row_reconciliation_matches_full_fifo_replay() {
2728        for unique in [false, true] {
2729            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2730                "name": name, "primaryKey": "id", "columns": [
2731                    {"name":"id", "type":"string", "nullable":false},
2732                    {"name":"project_id", "type":"string", "nullable":false},
2733                    {"name":"title", "type":"string", "nullable":false}],
2734                "scopes": [{"pattern":"project:{project_id}"}],
2735                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2736                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2737            })))});
2738            let mut incremental =
2739                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2740            let mut reference =
2741                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2742            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2743            for instance in [&mut incremental, &mut reference] {
2744                for table in &tables {
2745                    for index in 0..12 {
2746                        instance
2747                            .write_base_row(
2748                                table,
2749                                &vec![
2750                                    Some(ColumnValue::String(format!("row{index}"))),
2751                                    Some(ColumnValue::String("p0".into())),
2752                                    Some(ColumnValue::String(format!("base{index}"))),
2753                                ],
2754                                1,
2755                            )
2756                            .unwrap();
2757                    }
2758                }
2759                instance.rebuild_overlay_if_dirty();
2760            }
2761            for index in 0..80 {
2762                let mutations = (0..3)
2763                    .map(|offset| {
2764                        let table = tables[(index + offset) % 2].clone();
2765                        let id = format!("row{}", (index + offset) % 16);
2766                        if (index + offset) % 7 == 0 {
2767                            Mutation::Delete {
2768                                table,
2769                                row_id: id,
2770                                base_version: None,
2771                            }
2772                        } else {
2773                            Mutation::Upsert {
2774                                table,
2775                                values: Map::from_iter([
2776                                    ("id".into(), Value::from(id)),
2777                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
2778                                    (
2779                                        "title".into(),
2780                                        Value::from(format!("needle{}", (index + offset) % 16)),
2781                                    ),
2782                                ]),
2783                                base_version: None,
2784                            }
2785                        }
2786                    })
2787                    .collect::<Vec<_>>();
2788                for instance in [&mut incremental, &mut reference] {
2789                    instance.mutate(mutations.clone()).unwrap();
2790                }
2791                // Most frames touch only the table without unique constraints.
2792                // Mixed frames must retain full replay for both tables.
2793                let changes = (0..3)
2794                    .map(|offset| {
2795                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2796                        let id = format!("row{}", (index + offset) % 16);
2797                        let values = Map::from_iter([
2798                            ("id".into(), Value::from(id.clone())),
2799                            (
2800                                "project_id".into(),
2801                                Value::from(format!("p{}", (index + 1) % 3)),
2802                            ),
2803                            (
2804                                "title".into(),
2805                                Value::from(format!("remote{index}_{offset}")),
2806                            ),
2807                        ]);
2808                        ssp2::model::Change {
2809                            table_index: table_index as u16,
2810                            row_id: id.clone(),
2811                            op: if (index + offset) % 5 == 0 {
2812                                Op::Delete
2813                            } else {
2814                                Op::Upsert
2815                            },
2816                            row_version: Some(index as i64 + 2),
2817                            scopes: vec![],
2818                            row: Some(
2819                                encode_row_json(
2820                                    incremental.schema.table(&tables[table_index]).unwrap(),
2821                                    &id,
2822                                    &values,
2823                                    &incremental.encryption,
2824                                )
2825                                .unwrap(),
2826                            ),
2827                        }
2828                    })
2829                    .collect::<Vec<_>>();
2830                reference.overlay_dirty.set(true);
2831                incremental
2832                    .apply_commit_frame(&tables, &changes, None)
2833                    .unwrap();
2834                reference
2835                    .apply_commit_frame(&tables, &changes, None)
2836                    .unwrap();
2837                for table in &tables {
2838                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2839                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2840                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2841                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2842                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2843                    }
2844                }
2845                assert_eq!(incremental.local_revision(), reference.local_revision());
2846                assert_eq!(
2847                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2848                    serde_json::to_value(reference.drain_change_batches()).unwrap()
2849                );
2850            }
2851            assert_eq!(
2852                incremental.overlay_rebuild_count.get(),
2853                if unique { 81 } else { 21 }
2854            );
2855            assert_eq!(reference.overlay_rebuild_count.get(), 81);
2856        }
2857    }
2858
2859    #[test]
2860    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2861        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2862            "columns":[{"name":"id", "type":"string", "nullable":false},
2863                {"name":"title", "type":"string", "nullable":false}],
2864            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2865        let mut client =
2866            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2867        for (id, title) in [("a", "original"), ("b", "occupied")] {
2868            client
2869                .write_base_row(
2870                    "tasks",
2871                    &vec![
2872                        Some(ColumnValue::String(id.into())),
2873                        Some(ColumnValue::String(title.into())),
2874                    ],
2875                    1,
2876                )
2877                .unwrap();
2878        }
2879        client.rebuild_overlay_if_dirty();
2880        let pending = client
2881            .mutate(vec![Mutation::Upsert {
2882                table: "tasks".into(),
2883                values: Map::from_iter([
2884                    ("id".into(), Value::from("a")),
2885                    ("title".into(), Value::from("available")),
2886                ]),
2887                base_version: None,
2888            }])
2889            .unwrap();
2890        assert_eq!(
2891            client
2892                .conn
2893                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2894                    .get::<_, String>(
2895                    0
2896                ))
2897                .unwrap(),
2898            "available"
2899        );
2900        for (title, expected) in [("available", "original"), ("occupied", "available")] {
2901            let change = ssp2::model::Change {
2902                table_index: 0,
2903                row_id: "b".into(),
2904                op: Op::Upsert,
2905                row_version: Some(2),
2906                scopes: vec![],
2907                row: Some(
2908                    encode_row_json(
2909                        client.schema.table("tasks").unwrap(),
2910                        "b",
2911                        &Map::from_iter([
2912                            ("id".into(), Value::from("b")),
2913                            ("title".into(), Value::from(title)),
2914                        ]),
2915                        &client.encryption,
2916                    )
2917                    .unwrap(),
2918                ),
2919            };
2920            client
2921                .apply_commit_frame(&["tasks".into()], &[change], None)
2922                .unwrap();
2923            assert_eq!(
2924                client
2925                    .conn
2926                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2927                        .get::<_, String>(
2928                        0
2929                    ))
2930                    .unwrap(),
2931                expected
2932            );
2933            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2934        }
2935        assert_eq!(client.overlay_rebuild_count.get(), 3);
2936    }
2937
2938    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
2939        let schema = json!({ "version": 1, "tables": [
2940            { "name":"tasks", "primaryKey":"id", "columns":[
2941                {"name":"id","type":"string","nullable":false},
2942                {"name":"project_id","type":"string","nullable":false}],
2943                "scopes":[{"pattern":"project:{project_id}"}] },
2944            { "name":"catalogue", "primaryKey":"id", "columns":[
2945                {"name":"id","type":"string","nullable":false},
2946                {"name":"project_id","type":"string","nullable":false},
2947                {"name":"title","type":"string","nullable":false}],
2948                "scopes":[{"pattern":"project:{project_id}"}],
2949                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
2950        ] });
2951        let mut client =
2952            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
2953        if unchanged_rows > 0 {
2954            client.conn.execute_batch(&format!("WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i<{unchanged_rows}) INSERT INTO _syncular_base_catalogue SELECT 'c'||i,'p1','needle '||i,1 FROM n")).unwrap();
2955        }
2956        client.rebuild_overlay();
2957        client.conn.execute_batch("CREATE TABLE audit(tbl TEXT); CREATE TRIGGER catalogue_changed AFTER DELETE ON catalogue BEGIN INSERT INTO audit VALUES ('catalogue'); END;").unwrap();
2958        let mut elapsed = Vec::new();
2959        for round in 1..=rounds {
2960            let pending = client
2961                .mutate(vec![Mutation::Upsert {
2962                    table: "tasks".into(),
2963                    values: Map::from_iter([
2964                        ("id".into(), json!("t1")),
2965                        ("project_id".into(), json!("p1")),
2966                    ]),
2967                    base_version: None,
2968                }])
2969                .unwrap();
2970            let started = std::time::Instant::now();
2971            client
2972                .write_base_row(
2973                    "tasks",
2974                    &vec![
2975                        Some(ColumnValue::String("t1".into())),
2976                        Some(ColumnValue::String("p1".into())),
2977                    ],
2978                    round,
2979                )
2980                .unwrap();
2981            let mut report = SyncReport::default();
2982            client
2983                .handle_push_results(
2984                    &[Frame::PushResult {
2985                        client_commit_id: pending.clone(),
2986                        status: PushStatus::Applied,
2987                        commit_seq: Some(round),
2988                        results: vec![OpResult::Applied { op_index: 0 }],
2989                    }],
2990                    &HashSet::from([pending.as_str()]),
2991                    &HashMap::new(),
2992                    &mut report,
2993                    Some(&pending),
2994                )
2995                .unwrap();
2996            client.rebuild_overlay_if_dirty();
2997            elapsed.push(started.elapsed().as_nanos());
2998        }
2999        assert_eq!(
3000            client
3001                .conn
3002                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
3003                .unwrap(),
3004            0,
3005            "unchanged catalogue must never be copied"
3006        );
3007        assert_eq!(
3008            client
3009                .conn
3010                .query_row(
3011                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
3012                    [],
3013                    |r| r.get::<_, i64>(0)
3014                )
3015                .unwrap(),
3016            unchanged_rows
3017        );
3018        assert_eq!(
3019            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
3020            json!(rounds)
3021        );
3022        elapsed
3023    }
3024
3025    #[test]
3026    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
3027        let elapsed = measure_scoped_overlay(100_000, 1);
3028        eprintln!(
3029            "table-scoped overlay with 100k unchanged rows: {} ns",
3030            elapsed[0]
3031        );
3032    }
3033
3034    #[test]
3035    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
3036    fn benchmark_scoped_overlay() {
3037        for rows in [0, 100_000, 200_000] {
3038            let mut elapsed = measure_scoped_overlay(rows, 30);
3039            elapsed.sort_unstable();
3040            eprintln!(
3041                "{}",
3042                json!({"unchangedRows": rows, "rounds": elapsed.len(),
3043                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
3044                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
3045            );
3046        }
3047    }
3048
3049    #[test]
3050    fn released_context_discards_in_flight_ack_and_server_rows() {
3051        for barrier in ["preflight", "purge", "subscription"] {
3052            let mut client = client();
3053            client.set_meta(LOG_EPOCH_KEY, "epoch");
3054            client
3055                .subscribe(
3056                    "tasks".into(),
3057                    "tasks".into(),
3058                    vec![("project_id".into(), vec!["p1".into()])],
3059                    None,
3060                )
3061                .unwrap();
3062            let commit = client
3063                .mutate(vec![Mutation::Upsert {
3064                    table: "tasks".into(),
3065                    values: Map::from_iter([
3066                        ("id".into(), json!("local")),
3067                        ("project_id".into(), json!("p1")),
3068                    ]),
3069                    base_version: None,
3070                }])
3071                .unwrap();
3072            let prepared = client.prepare_sync_round(false).unwrap();
3073            client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
3074            let payload = encode_row_json(
3075                &client.schema.tables[0],
3076                "server",
3077                &Map::from_iter([
3078                    ("id".into(), json!("server")),
3079                    ("project_id".into(), json!("p1")),
3080                ]),
3081                &client.encryption,
3082            )
3083            .unwrap();
3084            let completed = crate::CompletedSyncRound {
3085                prepared,
3086                exchange: crate::round::ExchangeResult::Reply {
3087                    transport_failed: false,
3088                    downloads: Default::default(),
3089                    response: Ok(Message {
3090                        wire_version: WIRE_VERSION,
3091                        msg_kind: MsgKind::Response,
3092                        frames: vec![
3093                            Frame::RespHeader {
3094                                required_schema_version: None,
3095                                latest_schema_version: None,
3096                                log_epoch: Some("epoch".into()),
3097                                reset_required: Some(false),
3098                            },
3099                            Frame::PushResult {
3100                                client_commit_id: commit.clone(),
3101                                status: PushStatus::Applied,
3102                                commit_seq: Some(1),
3103                                results: vec![OpResult::Applied { op_index: 0 }],
3104                            },
3105                            Frame::SubStart {
3106                                id: "tasks".into(),
3107                                status: SubStatus::Active,
3108                                reason_code: String::new(),
3109                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
3110                                bootstrap: true,
3111                            },
3112                            Frame::Commit {
3113                                commit_seq: 1,
3114                                created_at_ms: 0,
3115                                actor_id: "actor".into(),
3116                                tables: vec!["tasks".into()],
3117                                changes: vec![ssp2::model::Change {
3118                                    table_index: 0,
3119                                    row_id: "server".into(),
3120                                    op: Op::Upsert,
3121                                    row_version: Some(1),
3122                                    scopes: vec![("project_id".into(), "p1".into())],
3123                                    row: Some(payload),
3124                                }],
3125                            },
3126                            Frame::SubEnd {
3127                                next_cursor: 1,
3128                                bootstrap_state: None,
3129                            },
3130                        ],
3131                    }),
3132                },
3133            };
3134            match barrier {
3135                "preflight" => client.begin_security_preflight(),
3136                "purge" => {
3137                    client
3138                        .purge_local_data(&LocalDataPurgeInput {
3139                            purge_id: "pending-round".into(),
3140                            targets: vec![LocalDataPurgeTarget {
3141                                table: "tasks".into(),
3142                                selectors: BTreeMap::from([(
3143                                    "project_id".into(),
3144                                    vec!["p1".into()],
3145                                )]),
3146                            }],
3147                        })
3148                        .unwrap();
3149                }
3150                "subscription" => {
3151                    client.unsubscribe("tasks");
3152                    client
3153                        .subscribe(
3154                            "tasks".into(),
3155                            "tasks".into(),
3156                            vec![("project_id".into(), vec!["p2".into()])],
3157                            None,
3158                        )
3159                        .unwrap();
3160                }
3161                _ => unreachable!(),
3162            }
3163            let pending_before_reply = client.pending_commit_ids();
3164            let revision = client.local_revision();
3165            let applied = client.apply_sync_round(completed);
3166            assert!(
3167                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
3168            );
3169            assert_eq!(client.local_revision(), revision);
3170            assert!(client
3171                .conn
3172                .query_row(
3173                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
3174                    [],
3175                    |r| r.get::<_, bool>(0)
3176                )
3177                .unwrap());
3178            assert_eq!(client.pending_commit_ids(), pending_before_reply);
3179        }
3180    }
3181
3182    #[test]
3183    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
3184        for failure in ["visible", "revision", "commit"] {
3185            let mut client = client();
3186            let id = client
3187                .mutate(vec![Mutation::Upsert {
3188                    table: "tasks".into(),
3189                    values: Map::from_iter([
3190                        ("id".into(), Value::from("t1")),
3191                        ("project_id".into(), Value::from("pending")),
3192                    ]),
3193                    base_version: None,
3194                }])
3195                .unwrap();
3196            client.drain_change_batches();
3197            let revision = client.local_revision();
3198            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
3199            let trigger = match failure {
3200                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
3201                "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"),
3202                _ => "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(),
3203            };
3204            client.conn.execute_batch(&trigger).unwrap();
3205            let change = ssp2::model::Change {
3206                table_index: 0,
3207                row_id: "t1".into(),
3208                op: Op::Upsert,
3209                row_version: Some(2),
3210                scopes: vec![],
3211                row: Some(
3212                    encode_row_json(
3213                        client.schema.table("tasks").unwrap(),
3214                        "t1",
3215                        &Map::from_iter([
3216                            ("id".into(), Value::from("t1")),
3217                            ("project_id".into(), Value::from("remote")),
3218                        ]),
3219                        &client.encryption,
3220                    )
3221                    .unwrap(),
3222                ),
3223            };
3224            assert!(client
3225                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
3226                .is_err());
3227            assert!(client.conn.is_autocommit());
3228            assert!(!client.overlay_dirty.get());
3229            assert_eq!(client.local_revision(), revision);
3230            assert!(client.drain_change_batches().is_empty());
3231            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
3232            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
3233            assert!(client
3234                .query("SELECT * FROM _syncular_base_tasks", &[])
3235                .unwrap()
3236                .is_empty());
3237            client
3238                .conn
3239                .execute_batch("DROP TRIGGER fail_pending")
3240                .unwrap();
3241            client
3242                .apply_commit_frame(&["tasks".into()], &[change], None)
3243                .unwrap();
3244            assert_eq!(client.local_revision(), revision + 1);
3245            assert_eq!(client.pending_commit_ids(), vec![id]);
3246            // The overlay keeps the pending write visible; the row now carries
3247            // the server row's version it is layered on (§7.1).
3248            let mut expected = before[0].clone();
3249            expected.insert("_syncular_version".to_owned(), Value::from(2));
3250            assert_eq!(
3251                client.query("SELECT * FROM tasks", &[]).unwrap(),
3252                vec![expected]
3253            );
3254            assert_eq!(
3255                client
3256                    .conn
3257                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
3258                        .get::<_, String>(0))
3259                    .unwrap(),
3260                "remote"
3261            );
3262        }
3263    }
3264
3265    #[test]
3266    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
3267        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
3268            "columns": [
3269                {"name":"id", "type":"string", "nullable":false},
3270                {"name":"project_id", "type":"string", "nullable":false},
3271                {"name":"title", "type":"string", "nullable":false}],
3272            "scopes": [{"pattern":"project:{project_id}"}],
3273            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3274            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
3275        let mut incremental =
3276            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
3277        let mut reference =
3278            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
3279        for instance in [&mut incremental, &mut reference] {
3280            for index in 0..16 {
3281                instance
3282                    .write_base_row(
3283                        "tasks",
3284                        &vec![
3285                            Some(ColumnValue::String(format!("task{index}"))),
3286                            Some(ColumnValue::String("p0".to_owned())),
3287                            Some(ColumnValue::String(format!("base{index}"))),
3288                        ],
3289                        1,
3290                    )
3291                    .unwrap();
3292            }
3293            instance.rebuild_overlay_if_dirty();
3294            instance.overlay_rebuild_count.set(0);
3295        }
3296        for index in 0..100 {
3297            let mutations = (0..1 + index % 4)
3298                .map(|offset| {
3299                    let id = format!("task{}", (index + offset) % 24);
3300                    if (index + offset) % 7 == 0 {
3301                        Mutation::Delete {
3302                            table: "tasks".to_owned(),
3303                            row_id: id,
3304                            base_version: None,
3305                        }
3306                    } else {
3307                        Mutation::Upsert {
3308                            table: "tasks".to_owned(),
3309                            values: Map::from_iter([
3310                                ("id".to_owned(), Value::from(id)),
3311                                (
3312                                    "project_id".to_owned(),
3313                                    Value::from(format!("p{}", index % 2)),
3314                                ),
3315                                (
3316                                    "title".to_owned(),
3317                                    Value::from(format!("needle{}", (index + offset) % 24)),
3318                                ),
3319                            ]),
3320                            base_version: None,
3321                        }
3322                    }
3323                })
3324                .collect::<Vec<_>>();
3325            reference.overlay_dirty.set(true);
3326            incremental.mutate(mutations.clone()).unwrap();
3327            reference.mutate(mutations).unwrap();
3328            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3329                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
3330                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
3331            }
3332            assert_eq!(incremental.local_revision(), reference.local_revision());
3333            assert_eq!(
3334                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3335                serde_json::to_value(reference.drain_change_batches()).unwrap()
3336            );
3337        }
3338        assert_eq!(incremental.outbox.len(), 100);
3339        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
3340        assert_eq!(reference.overlay_rebuild_count.get(), 100);
3341    }
3342
3343    #[test]
3344    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
3345        for fail_at in ["outbox", "revision"] {
3346            let mut client = client();
3347            let values = Map::from_iter([
3348                ("id".to_owned(), Value::from("t1")),
3349                ("project_id".to_owned(), Value::from("p1")),
3350            ]);
3351            let mutation = Mutation::Upsert {
3352                table: "tasks".to_owned(),
3353                values,
3354                base_version: None,
3355            };
3356            let trigger = if fail_at == "outbox" {
3357                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
3358            } else {
3359                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
3360            };
3361            client.conn.execute_batch(&trigger).unwrap();
3362            let revision = client.local_revision();
3363            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
3364            assert_eq!(client.local_revision(), revision);
3365            assert!(client.outbox.is_empty());
3366            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
3367            assert_eq!(
3368                client
3369                    .conn
3370                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
3371                        .get::<_, i64>(0))
3372                    .unwrap(),
3373                0
3374            );
3375            assert!(client.drain_change_batches().is_empty());
3376            assert!(!client.overlay_dirty.get());
3377            client
3378                .conn
3379                .execute_batch("DROP TRIGGER fail_append")
3380                .unwrap();
3381            client.mutate(vec![mutation]).unwrap();
3382            assert_eq!(client.outbox.len(), 1);
3383            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
3384        }
3385    }
3386
3387    #[test]
3388    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
3389        let mut client = client();
3390        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
3391        let values = Map::from_iter([
3392            ("id".to_owned(), Value::from("t1")),
3393            ("project_id".to_owned(), Value::from("p1")),
3394        ]);
3395        let payload = encode_row_json(
3396            client.schema.table("tasks").unwrap(),
3397            "t1",
3398            &values,
3399            &client.encryption,
3400        )
3401        .unwrap();
3402        let first = ssp2::model::Change {
3403            table_index: 0,
3404            row_id: "t1".to_owned(),
3405            op: Op::Upsert,
3406            row_version: Some(1),
3407            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
3408            row: Some(payload),
3409        };
3410        let mut invalid = first.clone();
3411        invalid.table_index = 1;
3412        let revision = client.local_revision();
3413        client.begin_observation("failed_mirror").unwrap();
3414        assert!(client
3415            .apply_commit_changes(&tables, &[first, invalid])
3416            .is_err());
3417        client.rollback_observation("failed_mirror");
3418        client.rebuild_overlay_if_dirty();
3419        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
3420        assert_eq!(
3421            client
3422                .conn
3423                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
3424                    .get::<_, i64>(
3425                    0
3426                ))
3427                .unwrap(),
3428            0
3429        );
3430        assert_eq!(client.local_revision(), revision);
3431        assert!(client.drain_change_batches().is_empty());
3432    }
3433
3434    #[test]
3435    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
3436        for failure in ["journal", "revision", "commit", "later-frame"] {
3437            let mut client = client();
3438            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3439            for id in ["first", "second"] {
3440                client
3441                    .mutate(vec![Mutation::Upsert {
3442                        table: "tasks".to_owned(),
3443                        values: Map::from_iter([
3444                            ("id".to_owned(), Value::from(id)),
3445                            ("project_id".to_owned(), Value::from("p1")),
3446                        ]),
3447                        base_version: None,
3448                    }])
3449                    .unwrap();
3450            }
3451            let (_, meta) = client.build_request(false);
3452            // A malicious or stale response must not drain an unsent local commit.
3453            client
3454                .mutate(vec![Mutation::Upsert {
3455                    table: "tasks".to_owned(),
3456                    values: Map::from_iter([
3457                        ("id".to_owned(), Value::from("later")),
3458                        ("project_id".to_owned(), Value::from("p1")),
3459                    ]),
3460                    base_version: None,
3461                }])
3462                .unwrap();
3463            let ids = client.pending_commit_ids();
3464            let rows = client
3465                .query("SELECT * FROM tasks ORDER BY id", &[])
3466                .unwrap();
3467            let revision = client.local_revision();
3468            client.drain_change_batches();
3469            if failure != "later-frame" {
3470                client.conn.execute_batch(match failure {
3471                    "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",
3472                    "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",
3473                    _ => "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",
3474                }).unwrap();
3475            }
3476            let mut response = Message {
3477                wire_version: WIRE_VERSION,
3478                msg_kind: MsgKind::Response,
3479                frames: vec![Frame::RespHeader {
3480                    required_schema_version: None,
3481                    latest_schema_version: None,
3482                    log_epoch: Some("epoch-1".to_owned()),
3483                    reset_required: Some(false),
3484                }],
3485            };
3486            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
3487                response.frames.push(Frame::PushResult {
3488                    client_commit_id: id.to_owned(),
3489                    status: PushStatus::Applied,
3490                    commit_seq: Some(1),
3491                    results: vec![OpResult::Applied { op_index: 0 }],
3492                });
3493            }
3494            if failure == "later-frame" {
3495                response.frames.push(Frame::Error {
3496                    code: "sync.invalid_request".to_owned(),
3497                    message: "later error".to_owned(),
3498                    category: "protocol".to_owned(),
3499                    retryable: false,
3500                    recommended_action: "retry".to_owned(),
3501                    details: None,
3502                });
3503            }
3504            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3505            let outcome = client.process_response(&mut transport, response.clone(), &meta);
3506            assert!(
3507                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
3508            );
3509            if failure == "later-frame" {
3510                assert_eq!(client.pending_commit_ids(), ids[2..]);
3511                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3512                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
3513                let batches = client.drain_change_batches();
3514                assert_eq!(batches.len(), 1);
3515                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
3516                assert_eq!(client.local_revision(), revision + 1);
3517                response.frames.pop();
3518            } else {
3519                assert_eq!(client.pending_commit_ids(), ids);
3520                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3521                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
3522                assert_eq!(client.local_revision(), revision);
3523                assert!(client.drain_change_batches().is_empty());
3524                assert_eq!(
3525                    client
3526                        .query("SELECT * FROM tasks ORDER BY id", &[])
3527                        .unwrap(),
3528                    rows
3529                );
3530                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
3531            }
3532            for frame in &mut response.frames {
3533                if let Frame::PushResult { status, .. } = frame {
3534                    *status = PushStatus::Cached;
3535                }
3536            }
3537            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
3538            else {
3539                panic!("retry failed");
3540            };
3541            assert_eq!(
3542                report.applied,
3543                if failure == "later-frame" {
3544                    vec![]
3545                } else {
3546                    ids[..2].to_vec()
3547                }
3548            );
3549            assert_eq!(client.pending_commit_ids(), ids[2..]);
3550            let batches = client.drain_change_batches();
3551            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
3552            if let Some(batch) = batches.first() {
3553                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
3554            }
3555            assert_eq!(client.local_revision(), revision + 1);
3556        }
3557    }
3558
3559    #[test]
3560    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
3561        for failure in ["revision", "journal", "commit"] {
3562            for status in ["applied", "cached", "rejected", "conflict"] {
3563                let mut client = client();
3564                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3565                for id in ["first", "later"] {
3566                    client
3567                        .mutate(vec![Mutation::Upsert {
3568                            table: "tasks".to_owned(),
3569                            values: Map::from_iter([
3570                                ("id".to_owned(), Value::from(id)),
3571                                ("project_id".to_owned(), Value::from("p1")),
3572                            ]),
3573                            base_version: None,
3574                        }])
3575                        .unwrap();
3576                }
3577                let (_, meta) = client.build_request(false);
3578                let ids = client.pending_commit_ids();
3579                let rows = client
3580                    .query("SELECT * FROM tasks ORDER BY id", &[])
3581                    .unwrap();
3582                let revision = client.local_revision();
3583                client.drain_change_batches();
3584                let (push_status, results) = match status {
3585                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
3586                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
3587                    "conflict" => (
3588                        PushStatus::Rejected,
3589                        vec![OpResult::Conflict {
3590                            op_index: 0,
3591                            code: "sync.version_conflict".to_owned(),
3592                            message: "conflict".to_owned(),
3593                            server_version: 1,
3594                            server_row: encode_row_json(
3595                                client.schema.table("tasks").unwrap(),
3596                                "first",
3597                                &Map::from_iter([
3598                                    ("id".to_owned(), Value::from("first")),
3599                                    ("project_id".to_owned(), Value::from("p1")),
3600                                ]),
3601                                &client.encryption,
3602                            )
3603                            .unwrap(),
3604                            conflict_columns: Vec::new(),
3605                        }],
3606                    ),
3607                    _ => (
3608                        PushStatus::Rejected,
3609                        vec![OpResult::Error {
3610                            op_index: 0,
3611                            code: "sync.validation_failed".to_owned(),
3612                            message: "rejected".to_owned(),
3613                            retryable: false,
3614                        }],
3615                    ),
3616                };
3617                let response = Message {
3618                    wire_version: WIRE_VERSION,
3619                    msg_kind: MsgKind::Response,
3620                    frames: vec![
3621                        Frame::RespHeader {
3622                            required_schema_version: None,
3623                            latest_schema_version: None,
3624                            log_epoch: Some("epoch-1".to_owned()),
3625                            reset_required: Some(false),
3626                        },
3627                        Frame::PushResult {
3628                            client_commit_id: ids[0].clone(),
3629                            status: push_status,
3630                            commit_seq: Some(1),
3631                            results,
3632                        },
3633                    ],
3634                };
3635                client.conn.execute_batch(match failure {
3636                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
3637                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
3638                    _ => "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",
3639                }).unwrap();
3640                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3641                let outcome = client.process_response(&mut transport, response.clone(), &meta);
3642                assert_eq!(
3643                    client.pending_commit_ids(),
3644                    ids,
3645                    "{failure}/{status}: pending identity"
3646                );
3647                assert_eq!(
3648                    client.local_revision(),
3649                    revision,
3650                    "{failure}/{status}: revision"
3651                );
3652                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3653                assert!(client.conflicts().is_empty());
3654                assert!(client.rejections().is_empty());
3655                assert!(client.drain_change_batches().is_empty());
3656                assert_eq!(
3657                    client
3658                        .query("SELECT * FROM tasks ORDER BY id", &[])
3659                        .unwrap(),
3660                    rows
3661                );
3662                assert!(
3663                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
3664                    "{failure}/{status}: failed round"
3665                );
3666                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
3667                assert!(matches!(
3668                    client.process_response(&mut transport, response, &meta),
3669                    SyncOutcome::Ok(_)
3670                ));
3671                assert_eq!(client.pending_commit_ids(), ids[1..]);
3672                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3673                assert_eq!(client.drain_change_batches().len(), 1);
3674            }
3675        }
3676    }
3677
3678    #[test]
3679    fn mixed_push_results_reconcile_and_prune_once_per_response() {
3680        let mut client = client();
3681        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3682        for index in 0..4 {
3683            client
3684                .mutate(vec![Mutation::Upsert {
3685                    table: "tasks".to_owned(),
3686                    values: Map::from_iter([
3687                        ("id".to_owned(), Value::from(format!("task-{index}"))),
3688                        ("project_id".to_owned(), Value::from("project-1")),
3689                    ]),
3690                    base_version: None,
3691                }])
3692                .expect("queue commit");
3693        }
3694
3695        let (_, request_meta) = client.build_request(false);
3696        let ids = &request_meta.pushed_ids;
3697        assert_eq!(ids.len(), 4);
3698        let response = Message {
3699            wire_version: WIRE_VERSION,
3700            msg_kind: MsgKind::Response,
3701            frames: vec![
3702                Frame::RespHeader {
3703                    required_schema_version: None,
3704                    latest_schema_version: None,
3705                    log_epoch: Some("epoch-1".to_owned()),
3706                    reset_required: Some(false),
3707                },
3708                Frame::PushResult {
3709                    client_commit_id: ids[0].clone(),
3710                    status: PushStatus::Applied,
3711                    commit_seq: Some(1),
3712                    results: vec![OpResult::Applied { op_index: 0 }],
3713                },
3714                Frame::PushResult {
3715                    client_commit_id: ids[1].clone(),
3716                    status: PushStatus::Cached,
3717                    commit_seq: Some(2),
3718                    results: vec![OpResult::Applied { op_index: 0 }],
3719                },
3720                Frame::PushResult {
3721                    client_commit_id: ids[2].clone(),
3722                    status: PushStatus::Rejected,
3723                    commit_seq: None,
3724                    results: vec![OpResult::Error {
3725                        op_index: 0,
3726                        code: "sync.validation_failed".to_owned(),
3727                        message: "rejected".to_owned(),
3728                        retryable: false,
3729                    }],
3730                },
3731                Frame::PushResult {
3732                    client_commit_id: ids[3].clone(),
3733                    status: PushStatus::Rejected,
3734                    commit_seq: None,
3735                    results: vec![OpResult::Error {
3736                        op_index: 0,
3737                        code: "sync.idempotency_cache_miss".to_owned(),
3738                        message: "retry".to_owned(),
3739                        retryable: true,
3740                    }],
3741                },
3742            ],
3743        };
3744        let mut transport =
3745            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3746
3747        client.overlay_rebuild_count.set(0);
3748        client.outcome_prune_count.set(0);
3749        let outcome = client.process_response(&mut transport, response, &request_meta);
3750        let SyncOutcome::Ok(report) = outcome else {
3751            panic!("mixed push-result response failed");
3752        };
3753
3754        assert_eq!(report.applied, ids[..2]);
3755        assert_eq!(report.rejected, ids[2..3]);
3756        assert_eq!(report.retryable, ids[3..4]);
3757        assert_eq!(client.pending_commit_ids(), ids[3..4]);
3758        assert_eq!(
3759            client
3760                .query("SELECT id FROM tasks ORDER BY id", &[])
3761                .expect("query visible overlay"),
3762            ["task-0", "task-1", "task-3"]
3763                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
3764                .to_vec()
3765        );
3766        assert_eq!(client.overlay_rebuild_count.get(), 1);
3767        assert_eq!(client.outcome_prune_count.get(), 1);
3768    }
3769
3770    #[test]
3771    fn secondary_unique_collision_preserves_existing_synced_row() {
3772        let client = SyncClient::new(
3773            "unique-upsert-test".to_owned(),
3774            &json!({
3775                "version": 1,
3776                "tables": [{
3777                    "name": "tasks",
3778                    "primaryKey": "id",
3779                    "columns": [
3780                        { "name": "id", "type": "string", "nullable": false },
3781                        { "name": "project_id", "type": "string", "nullable": false },
3782                        { "name": "title", "type": "string", "nullable": false }
3783                    ],
3784                    "scopes": [{ "pattern": "project:{project_id}" }],
3785                    "indexes": [{
3786                        "name": "tasks_by_project_title",
3787                        "columns": ["project_id", "title"],
3788                        "unique": true
3789                    }]
3790                }]
3791            }),
3792            ClientLimits::default(),
3793        )
3794        .expect("test client");
3795        let table = client.schema.table("tasks").expect("tasks table");
3796        let sql = client.insert_row_sql(&base_table("tasks"), table);
3797
3798        client
3799            .conn
3800            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
3801            .expect("insert first row");
3802        client
3803            .conn
3804            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3805            .expect("update same primary key");
3806        client
3807            .conn
3808            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3809            .expect("insert second row");
3810        assert!(client
3811            .conn
3812            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3813            .is_err());
3814
3815        let rows = client
3816            .conn
3817            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3818            .expect("prepare rows")
3819            .query_map([], |row| {
3820                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3821            })
3822            .expect("query rows")
3823            .collect::<Result<Vec<_>, _>>()
3824            .expect("collect rows");
3825        assert_eq!(
3826            rows,
3827            vec![
3828                ("t1".to_owned(), "updated".to_owned()),
3829                ("t2".to_owned(), "original".to_owned())
3830            ]
3831        );
3832    }
3833
3834    #[test]
3835    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3836        let path = std::env::temp_dir().join(format!(
3837            "syncular-startup-intent-{}.db",
3838            uuid::Uuid::new_v4()
3839        ));
3840        let schema = json!({
3841            "version": 1,
3842            "tables": [{
3843                "name": "tasks",
3844                "primaryKey": "id",
3845                "columns": [
3846                    { "name": "id", "type": "string", "nullable": false },
3847                    { "name": "project_id", "type": "string", "nullable": false }
3848                ],
3849                "scopes": [{ "pattern": "project:{project_id}" }]
3850            }]
3851        });
3852
3853        {
3854            let mut first = SyncClient::open_path_with_identity(
3855                None,
3856                &schema,
3857                ClientLimits::default(),
3858                path.to_str().expect("UTF-8 temp path"),
3859            )
3860            .expect("first open");
3861            first
3862                .set_window(
3863                    &WindowBase {
3864                        table: "tasks".to_owned(),
3865                        variable: "project_id".to_owned(),
3866                        fixed_scopes: Vec::new(),
3867                        params: None,
3868                    },
3869                    &["persisted".to_owned()],
3870                )
3871                .expect("persist window");
3872        }
3873
3874        let mut reopened = SyncClient::open_path_with_identity(
3875            None,
3876            &schema,
3877            ClientLimits::default(),
3878            path.to_str().expect("UTF-8 temp path"),
3879        )
3880        .expect("reopen");
3881        assert!(reopened.sync_needed());
3882        assert!(matches!(
3883            reopened.drain_sync_intents().as_slice(),
3884            [SyncIntent::Interactive]
3885        ));
3886        drop(reopened);
3887        std::fs::remove_file(path).expect("remove temp database");
3888    }
3889
3890    #[test]
3891    fn reopening_preserves_immutable_subscription_identity_and_progress() {
3892        let path = std::env::temp_dir().join(format!(
3893            "syncular-subscription-identity-{}.db",
3894            uuid::Uuid::new_v4()
3895        ));
3896        let schema = json!({
3897            "version": 1,
3898            "tables": [
3899                {
3900                    "name": "tasks",
3901                    "primaryKey": "id",
3902                    "columns": [
3903                        { "name": "id", "type": "string", "nullable": false },
3904                        { "name": "project_id", "type": "string", "nullable": false }
3905                    ],
3906                    "scopes": [{ "pattern": "project:{project_id}" }]
3907                },
3908                {
3909                    "name": "docs",
3910                    "primaryKey": "id",
3911                    "columns": [
3912                        { "name": "id", "type": "string", "nullable": false },
3913                        { "name": "org_id", "type": "string", "nullable": false },
3914                        { "name": "project_id", "type": "string", "nullable": false }
3915                    ],
3916                    "scopes": [
3917                        { "pattern": "org:{org_id}" },
3918                        { "pattern": "project:{projectId}", "column": "project_id" }
3919                    ]
3920                }
3921            ]
3922        });
3923
3924        {
3925            let mut first = SyncClient::open_path_with_identity(
3926                None,
3927                &schema,
3928                ClientLimits::default(),
3929                path.to_str().expect("UTF-8 temp path"),
3930            )
3931            .expect("first open");
3932            first
3933                .subscribe(
3934                    "stable-subscription".to_owned(),
3935                    "tasks".to_owned(),
3936                    vec![(
3937                        "project_id".to_owned(),
3938                        vec!["p2".to_owned(), "p1".to_owned()],
3939                    )],
3940                    Some(r#"{"view":"v1"}"#.to_owned()),
3941                )
3942                .expect("persist subscription");
3943            let persisted = {
3944                let subscription = first
3945                    .subs
3946                    .iter_mut()
3947                    .find(|subscription| subscription.id == "stable-subscription")
3948                    .expect("subscription");
3949                subscription.cursor = 41;
3950                subscription.bootstrap_state = Some("resume-token".to_owned());
3951                subscription.effective = Some(vec![(
3952                    "project_id".to_owned(),
3953                    vec!["p1".to_owned(), "p2".to_owned()],
3954                )]);
3955                subscription.synced_once = true;
3956                subscription.clone()
3957            };
3958            first.persist_sub(&persisted).unwrap();
3959        }
3960
3961        let mut reopened = SyncClient::open_path_with_identity(
3962            None,
3963            &schema,
3964            ClientLimits::default(),
3965            path.to_str().expect("UTF-8 temp path"),
3966        )
3967        .expect("reopen");
3968        let progress = reopened
3969            .subscription_state("stable-subscription")
3970            .expect("persisted state");
3971        assert_eq!(progress.cursor, 41);
3972        assert!(progress.has_resume_token);
3973
3974        reopened
3975            .subscribe(
3976                "stable-subscription".to_owned(),
3977                "tasks".to_owned(),
3978                vec![(
3979                    "project_id".to_owned(),
3980                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3981                )],
3982                Some(r#"{"view":"v1"}"#.to_owned()),
3983            )
3984            .expect("canonical intent is idempotent");
3985        assert_eq!(
3986            reopened
3987                .subscription_state("stable-subscription")
3988                .expect("unchanged state")
3989                .cursor,
3990            progress.cursor
3991        );
3992
3993        for (table, scopes, params) in [
3994            (
3995                "tasks",
3996                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3997                Some(r#"{"view":"v1"}"#.to_owned()),
3998            ),
3999            (
4000                "tasks",
4001                vec![(
4002                    "project_id".to_owned(),
4003                    vec!["p2".to_owned(), "p1".to_owned()],
4004                )],
4005                Some(r#"{"view":"v2"}"#.to_owned()),
4006            ),
4007            (
4008                "docs",
4009                vec![
4010                    ("org_id".to_owned(), vec!["o1".to_owned()]),
4011                    ("projectId".to_owned(), vec!["p1".to_owned()]),
4012                ],
4013                Some(r#"{"view":"v1"}"#.to_owned()),
4014            ),
4015        ] {
4016            let error = reopened
4017                .subscribe(
4018                    "stable-subscription".to_owned(),
4019                    table.to_owned(),
4020                    scopes,
4021                    params,
4022                )
4023                .expect_err("identity rebind must fail");
4024            assert!(error.starts_with("client.subscription_intent_mismatch:"));
4025            assert_eq!(
4026                reopened
4027                    .subscription_state("stable-subscription")
4028                    .expect("unchanged state")
4029                    .cursor,
4030                progress.cursor
4031            );
4032        }
4033
4034        drop(reopened);
4035        std::fs::remove_file(path).expect("remove temp database");
4036    }
4037
4038    #[test]
4039    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
4040        let path = std::env::temp_dir().join(format!(
4041            "syncular-satisfied-schema-floor-{}.db",
4042            uuid::Uuid::new_v4()
4043        ));
4044        let schema = json!({
4045            "version": 23,
4046            "tables": [{
4047                "name": "tasks",
4048                "primaryKey": "id",
4049                "columns": [
4050                    { "name": "id", "type": "string", "nullable": false },
4051                    { "name": "project_id", "type": "string", "nullable": false }
4052                ],
4053                "scopes": [{ "pattern": "project:{project_id}" }]
4054            }]
4055        });
4056
4057        {
4058            let mut first = SyncClient::open_path_with_identity(
4059                None,
4060                &schema,
4061                ClientLimits::default(),
4062                path.to_str().expect("UTF-8 temp path"),
4063            )
4064            .expect("first open");
4065            first
4066                .subscribe(
4067                    "tasks".to_owned(),
4068                    "tasks".to_owned(),
4069                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
4070                    None,
4071                )
4072                .expect("persist subscription");
4073            first.set_schema_floor(Some(SchemaFloor {
4074                required_schema_version: Some(22),
4075                latest_schema_version: Some(22),
4076            }));
4077        }
4078
4079        let mut reopened = SyncClient::open_path_with_identity(
4080            None,
4081            &schema,
4082            ClientLimits::default(),
4083            path.to_str().expect("UTF-8 temp path"),
4084        )
4085        .expect("reopen");
4086        assert!(reopened.schema_floor().is_none());
4087        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
4088        assert!(reopened.sync_needed());
4089        assert!(matches!(
4090            reopened.drain_sync_intents().as_slice(),
4091            [SyncIntent::Interactive]
4092        ));
4093        drop(reopened);
4094        std::fs::remove_file(path).expect("remove temp database");
4095    }
4096
4097    #[test]
4098    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
4099        let path = std::env::temp_dir().join(format!(
4100            "syncular-unsatisfied-schema-floor-{}.db",
4101            uuid::Uuid::new_v4()
4102        ));
4103        let schema = json!({
4104            "version": 1,
4105            "tables": [{
4106                "name": "tasks",
4107                "primaryKey": "id",
4108                "columns": [
4109                    { "name": "id", "type": "string", "nullable": false },
4110                    { "name": "project_id", "type": "string", "nullable": false }
4111                ],
4112                "scopes": [{ "pattern": "project:{project_id}" }]
4113            }]
4114        });
4115
4116        {
4117            let mut first = SyncClient::open_path_with_identity(
4118                None,
4119                &schema,
4120                ClientLimits::default(),
4121                path.to_str().expect("UTF-8 temp path"),
4122            )
4123            .expect("first open");
4124            first
4125                .subscribe(
4126                    "tasks".to_owned(),
4127                    "tasks".to_owned(),
4128                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
4129                    None,
4130                )
4131                .expect("persist subscription");
4132            first.set_schema_floor(Some(SchemaFloor {
4133                required_schema_version: Some(2),
4134                latest_schema_version: Some(2),
4135            }));
4136        }
4137
4138        let reopened = SyncClient::open_path_with_identity(
4139            None,
4140            &schema,
4141            ClientLimits::default(),
4142            path.to_str().expect("UTF-8 temp path"),
4143        )
4144        .expect("reopen");
4145        assert_eq!(
4146            reopened.schema_floor(),
4147            Some(&SchemaFloor {
4148                required_schema_version: Some(2),
4149                latest_schema_version: Some(2),
4150            })
4151        );
4152        assert!(!reopened.sync_needed());
4153        drop(reopened);
4154        std::fs::remove_file(path).expect("remove temp database");
4155    }
4156
4157    #[test]
4158    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
4159        let path = std::env::temp_dir().join(format!(
4160            "syncular-indexed-column-bump-{}.db",
4161            uuid::Uuid::new_v4()
4162        ));
4163        let old_schema = json!({
4164            "version": 1,
4165            "tables": [
4166                {
4167                    "name": "tasks",
4168                    "primaryKey": "id",
4169                    "columns": [
4170                        { "name": "id", "type": "string", "nullable": false },
4171                        { "name": "project_id", "type": "string", "nullable": false }
4172                    ],
4173                    "scopes": [{ "pattern": "project:{project_id}" }]
4174                },
4175                {
4176                    "name": "legacy",
4177                    "primaryKey": "id",
4178                    "columns": [
4179                        { "name": "id", "type": "string", "nullable": false },
4180                        { "name": "project_id", "type": "string", "nullable": false }
4181                    ],
4182                    "scopes": [{ "pattern": "project:{project_id}" }]
4183                }
4184            ]
4185        });
4186        {
4187            let mut first = SyncClient::open_path_with_identity(
4188                None,
4189                &old_schema,
4190                ClientLimits::default(),
4191                path.to_str().expect("UTF-8 temp path"),
4192            )
4193            .expect("open old schema");
4194            first
4195                .subscribe(
4196                    "legacy-sub".to_owned(),
4197                    "legacy".to_owned(),
4198                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
4199                    None,
4200                )
4201                .expect("persist legacy subscription");
4202        }
4203
4204        let new_schema = json!({
4205            "version": 2,
4206            "tables": [{
4207                "name": "tasks",
4208                "primaryKey": "id",
4209                "columns": [
4210                    { "name": "id", "type": "string", "nullable": false },
4211                    { "name": "project_id", "type": "string", "nullable": false },
4212                    { "name": "facility_membership_id", "type": "string", "nullable": true }
4213                ],
4214                "scopes": [{ "pattern": "project:{project_id}" }],
4215                "indexes": [{
4216                    "name": "tasks_by_membership",
4217                    "columns": ["project_id", "facility_membership_id"],
4218                    "unique": false
4219                }]
4220            }]
4221        });
4222        let upgraded = SyncClient::open_path_with_identity(
4223            None,
4224            &new_schema,
4225            ClientLimits::default(),
4226            path.to_str().expect("UTF-8 temp path"),
4227        )
4228        .expect("open upgraded schema");
4229        let columns = upgraded
4230            .conn
4231            .prepare("PRAGMA table_info(tasks)")
4232            .expect("prepare columns")
4233            .query_map([], |row| row.get::<_, String>(1))
4234            .expect("query columns")
4235            .collect::<Result<Vec<_>, _>>()
4236            .expect("collect columns");
4237        assert!(columns.contains(&"facility_membership_id".to_owned()));
4238        let index_count: i64 = upgraded
4239            .conn
4240            .query_row(
4241                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
4242                [],
4243                |row| row.get(0),
4244            )
4245            .expect("query index");
4246        assert_eq!(index_count, 1);
4247        assert!(upgraded.subscription_state("legacy-sub").is_none());
4248        drop(upgraded);
4249        std::fs::remove_file(path).expect("remove temp database");
4250    }
4251
4252    #[test]
4253    fn migrates_pre_envelope_outcome_journal_additively() {
4254        let path = std::env::temp_dir().join(format!(
4255            "syncular-outcome-migration-{}.db",
4256            uuid::Uuid::new_v4()
4257        ));
4258        let conn = Connection::open(&path).expect("open legacy database");
4259        conn.execute_batch(
4260            "CREATE TABLE _syncular_commit_outcomes (
4261               seq INTEGER PRIMARY KEY AUTOINCREMENT,
4262               client_commit_id TEXT NOT NULL UNIQUE,
4263               status TEXT NOT NULL,
4264               recorded_at_ms INTEGER NOT NULL,
4265               results_json TEXT NOT NULL,
4266               resolution TEXT NOT NULL DEFAULT 'active',
4267               resolved_at_ms INTEGER,
4268               replacement_client_commit_id TEXT);",
4269        )
4270        .expect("create legacy outcome journal");
4271        drop(conn);
4272        let schema = json!({
4273            "version": 1,
4274            "tables": [{
4275                "name": "tasks",
4276                "primaryKey": "id",
4277                "columns": [
4278                    { "name": "id", "type": "string", "nullable": false },
4279                    { "name": "project_id", "type": "string", "nullable": false }
4280                ],
4281                "scopes": [{ "pattern": "project:{project_id}" }]
4282            }]
4283        });
4284        let client = SyncClient::open_path(
4285            "migration-native".to_owned(),
4286            &schema,
4287            ClientLimits::default(),
4288            path.to_str().expect("UTF-8 temp path"),
4289        )
4290        .expect("migrate database");
4291        let has_operations = client
4292            .conn
4293            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
4294            .expect("prepare table info")
4295            .query_map([], |row| row.get::<_, String>(1))
4296            .expect("query table info")
4297            .filter_map(Result::ok)
4298            .any(|column| column == "operations_json");
4299        assert!(has_operations);
4300        drop(client);
4301        std::fs::remove_file(path).expect("remove temp database");
4302    }
4303
4304    #[test]
4305    fn retained_rejection_survives_file_reopen_and_security_purge() {
4306        let path =
4307            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
4308        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
4309            { "name": "id", "type": "string", "nullable": false },
4310            { "name": "project_id", "type": "string", "nullable": false },
4311            { "name": "title", "type": "string", "nullable": false }
4312        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
4313        {
4314            let mut first = SyncClient::open_path(
4315                "retained-native".to_owned(),
4316                &schema,
4317                ClientLimits::default(),
4318                path.to_str().unwrap(),
4319            )
4320            .unwrap();
4321            first
4322                .write_base_row(
4323                    "tasks",
4324                    &vec![
4325                        Some(ColumnValue::String("t1".into())),
4326                        Some(ColumnValue::String("p1".into())),
4327                        Some(ColumnValue::String("server".into())),
4328                    ],
4329                    1,
4330                )
4331                .unwrap();
4332            first.rebuild_overlay();
4333            let operation = OutboxOp {
4334                table: "tasks".into(),
4335                row_id: "t1".into(),
4336                upsert: true,
4337                base_version: Some(1),
4338                values: Some(Map::from_iter([
4339                    ("id".into(), json!("t1")),
4340                    ("title".into(), json!("mine")),
4341                ])),
4342            };
4343            first
4344                .retain_failed_commit("retained", std::slice::from_ref(&operation))
4345                .unwrap();
4346            first
4347                .persist_commit_outcome(
4348                    "retained",
4349                    CommitOutcomeStatus::Rejected,
4350                    &[],
4351                    Some(&[operation]),
4352                )
4353                .unwrap();
4354            first.rebuild_overlay();
4355        }
4356        {
4357            let mut reopened = SyncClient::open_path(
4358                "retained-native".to_owned(),
4359                &schema,
4360                ClientLimits::default(),
4361                path.to_str().unwrap(),
4362            )
4363            .unwrap();
4364            let row = reopened
4365                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4366                .unwrap();
4367            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
4368            assert_eq!(
4369                reopened
4370                    .commit_outcome("retained")
4371                    .unwrap()
4372                    .unwrap()
4373                    .retained_rows
4374                    .unwrap()[0]
4375                    .server_row
4376                    .as_ref()
4377                    .unwrap()["title"],
4378                json!("server")
4379            );
4380            reopened
4381                .conn
4382                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
4383                .unwrap();
4384            reopened.rebuild_overlay();
4385            assert!(reopened
4386                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4387                .is_none());
4388            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
4389            assert!(retained.retained_rows.is_some());
4390            assert_eq!(
4391                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
4392                json!("mine")
4393            );
4394            reopened
4395                .purge_local_data(&LocalDataPurgeInput {
4396                    purge_id: "protected-purge".into(),
4397                    targets: vec![LocalDataPurgeTarget {
4398                        table: "tasks".into(),
4399                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
4400                    }],
4401                })
4402                .unwrap();
4403            assert!(reopened
4404                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4405                .is_none());
4406            assert!(reopened
4407                .commit_outcome("retained")
4408                .unwrap()
4409                .unwrap()
4410                .retained_rows
4411                .is_none());
4412        }
4413        std::fs::remove_file(path).unwrap();
4414    }
4415
4416    #[test]
4417    fn durable_conflict_outcome_and_resolution_survive_reopen() {
4418        let path = std::env::temp_dir().join(format!(
4419            "syncular-durable-outcome-{}.db",
4420            uuid::Uuid::new_v4()
4421        ));
4422        let schema = json!({
4423            "version": 1,
4424            "tables": [{
4425                "name": "tasks",
4426                "primaryKey": "id",
4427                "columns": [
4428                    { "name": "id", "type": "string", "nullable": false },
4429                    { "name": "project_id", "type": "string", "nullable": false }
4430                ],
4431                "scopes": [{ "pattern": "project:{project_id}" }]
4432            }]
4433        });
4434        let conflict = ConflictRecord {
4435            client_commit_id: "losing-commit".to_owned(),
4436            op_index: 0,
4437            table: "tasks".to_owned(),
4438            row_id: "t1".to_owned(),
4439            code: "sync.version_conflict".to_owned(),
4440            message: "stale base version".to_owned(),
4441            server_version: 2,
4442            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
4443            conflict_columns: vec!["project_id".to_owned()],
4444            operation: Some(CommitOperation {
4445                table: "tasks".to_owned(),
4446                row_id: "t1".to_owned(),
4447                op: "upsert".to_owned(),
4448                base_version: Some(1),
4449                values: None,
4450            }),
4451        };
4452        let failed_operations = vec![
4453            OutboxOp {
4454                upsert: true,
4455                table: "tasks".to_owned(),
4456                row_id: "t1".to_owned(),
4457                base_version: Some(1),
4458                values: None,
4459            },
4460            OutboxOp {
4461                upsert: true,
4462                table: "tasks".to_owned(),
4463                row_id: "status-event-1".to_owned(),
4464                base_version: Some(0),
4465                values: None,
4466            },
4467        ];
4468
4469        {
4470            let mut first = SyncClient::open_path(
4471                "durable-native".to_owned(),
4472                &schema,
4473                ClientLimits::default(),
4474                path.to_str().expect("UTF-8 temp path"),
4475            )
4476            .expect("first open");
4477            first
4478                .begin_observation("test_outcome")
4479                .expect("begin outcome");
4480            first
4481                .persist_commit_outcome(
4482                    "losing-commit",
4483                    CommitOutcomeStatus::Conflict,
4484                    &[CommitOperationOutcome::Conflict {
4485                        conflict: conflict.clone(),
4486                    }],
4487                    Some(&failed_operations),
4488                )
4489                .expect("persist outcome");
4490            first.conflicts.push(conflict);
4491            first
4492                .finish_observation(
4493                    "test_outcome",
4494                    ChangeAccumulator {
4495                        conflicts: true,
4496                        outcomes: true,
4497                        ..ChangeAccumulator::default()
4498                    },
4499                )
4500                .expect("commit outcome");
4501        }
4502
4503        {
4504            let mut reopened = SyncClient::open_path(
4505                "durable-native".to_owned(),
4506                &schema,
4507                ClientLimits::default(),
4508                path.to_str().expect("UTF-8 temp path"),
4509            )
4510            .expect("reopen");
4511            assert_eq!(reopened.conflicts().len(), 1);
4512            assert_eq!(
4513                reopened.conflicts()[0].conflict_columns,
4514                vec!["project_id".to_owned()],
4515                "conflictColumns survives the journal round trip"
4516            );
4517            let outcome = reopened
4518                .commit_outcome("losing-commit")
4519                .expect("read outcome")
4520                .expect("outcome");
4521            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
4522            let operations = outcome.operations.expect("aggregate envelope");
4523            assert_eq!(operations.len(), 2);
4524            assert_eq!(operations[1].row_id, "status-event-1");
4525            let resolved = reopened
4526                .resolve_commit_outcome(ResolveCommitOutcomeInput {
4527                    client_commit_id: "losing-commit".to_owned(),
4528                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
4529                    replacement_client_commit_id: None,
4530                })
4531                .expect("resolve");
4532            assert_eq!(
4533                resolved.resolution,
4534                CommitOutcomeResolution::ResolvedKeepServer
4535            );
4536            assert!(reopened.conflicts().is_empty());
4537        }
4538
4539        let reopened = SyncClient::open_path(
4540            "durable-native".to_owned(),
4541            &schema,
4542            ClientLimits::default(),
4543            path.to_str().expect("UTF-8 temp path"),
4544        )
4545        .expect("second reopen");
4546        assert!(reopened.conflicts().is_empty());
4547        assert_eq!(
4548            reopened
4549                .commit_outcome("losing-commit")
4550                .expect("read outcome")
4551                .expect("outcome")
4552                .resolution,
4553            CommitOutcomeResolution::ResolvedKeepServer
4554        );
4555        drop(reopened);
4556        std::fs::remove_file(path).expect("remove temp database");
4557    }
4558
4559    #[test]
4560    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
4561        let path =
4562            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
4563        let schema = json!({
4564            "version": 1,
4565            "tables": [{
4566                "name": "tasks",
4567                "primaryKey": "id",
4568                "columns": [
4569                    { "name": "id", "type": "string", "nullable": false },
4570                    { "name": "project_id", "type": "string", "nullable": false }
4571                ],
4572                "scopes": [{ "pattern": "project:{project_id}" }]
4573            }]
4574        });
4575        let mut client = SyncClient::open_path_with_identity(
4576            Some("sidecar-client".to_owned()),
4577            &schema,
4578            ClientLimits::default(),
4579            path.to_str().expect("UTF-8 temp path"),
4580        )
4581        .expect("open owner");
4582        let base = WindowBase {
4583            table: "tasks".to_owned(),
4584            variable: "project_id".to_owned(),
4585            fixed_scopes: Vec::new(),
4586            params: None,
4587        };
4588        client
4589            .set_window(&base, &["one".to_owned()])
4590            .expect("set window");
4591        client
4592            .mutate(vec![Mutation::Upsert {
4593                table: "tasks".to_owned(),
4594                values: Map::from_iter([
4595                    ("id".to_owned(), Value::from("t1")),
4596                    ("project_id".to_owned(), Value::from("one")),
4597                ]),
4598                base_version: None,
4599            }])
4600            .expect("local mutate");
4601
4602        let coverage = [WindowCoverage {
4603            base,
4604            units: vec!["one".to_owned(), "missing".to_owned()],
4605        }];
4606        let owner = client
4607            .query_snapshot(
4608                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4609                &[],
4610                &coverage,
4611                None,
4612            )
4613            .expect("owner snapshot");
4614        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
4615        let sidecar = reader
4616            .query_snapshot(
4617                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4618                &[],
4619                &coverage,
4620            )
4621            .expect("sidecar snapshot");
4622
4623        assert_eq!(sidecar.revision, owner.revision);
4624        assert_eq!(sidecar.rows, owner.rows);
4625        assert_eq!(
4626            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
4627            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
4628        );
4629        assert_eq!(sidecar.revision, "2");
4630        assert_eq!(sidecar.rows[0]["id"], "t1");
4631        assert_eq!(sidecar.rows[0]["server_version"], -1);
4632        assert!(!sidecar.coverage.complete);
4633        assert_eq!(sidecar.coverage.pending.len(), 1);
4634        assert_eq!(sidecar.coverage.missing.len(), 1);
4635
4636        drop(reader);
4637        drop(client);
4638        std::fs::remove_file(path).expect("remove temp database");
4639    }
4640
4641    #[test]
4642    fn owned_query_failures_classify_order_bound_and_clear() {
4643        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4644            rusqlite::ffi::Error::new(266),
4645            Some("private SQLite prose".to_owned()),
4646        ));
4647        assert_eq!(io.code, Some("client.storage_io"));
4648        assert_eq!(io.sqlite_code, Some(266));
4649        assert_eq!(io.message, "local SQLite storage I/O failed");
4650        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4651            rusqlite::ffi::Error::new(26),
4652            None,
4653        ));
4654        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
4655
4656        let mut client = client();
4657        let generic = QueryReadFailure::from("private query prose".to_owned());
4658        client.record_query_read(
4659            QueryOwner {
4660                id: "first",
4661                tables: &["tasks", "tasks"],
4662            },
4663            Some(&generic),
4664        );
4665        client.record_query_read(
4666            QueryOwner {
4667                id: "second",
4668                tables: &["tasks"],
4669            },
4670            Some(&generic),
4671        );
4672        client.record_query_read(
4673            QueryOwner {
4674                id: "first",
4675                tables: &["docs"],
4676            },
4677            Some(&io),
4678        );
4679        let failures = &client
4680            .diagnostics_snapshot(&Default::default())
4681            .expect("diagnostics")
4682            .query_failures;
4683        assert_eq!(
4684            failures
4685                .iter()
4686                .map(|entry| entry.id.as_str())
4687                .collect::<Vec<_>>(),
4688            vec!["second", "first"]
4689        );
4690        assert_eq!(failures[1].tables, vec!["docs"]);
4691        assert_eq!(failures[1].code, "client.storage_io");
4692
4693        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
4694            client.record_query_read(
4695                QueryOwner {
4696                    id: &format!("query-{index}"),
4697                    tables: &["tasks"],
4698                },
4699                Some(&generic),
4700            );
4701        }
4702        let failures = &client
4703            .diagnostics_snapshot(&Default::default())
4704            .expect("bounded diagnostics")
4705            .query_failures;
4706        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
4707        assert_eq!(failures.last().expect("last failure").id, "query-256");
4708
4709        client
4710            .query_snapshot(
4711                "SELECT id FROM tasks",
4712                &[],
4713                &[],
4714                Some(QueryOwner {
4715                    id: "query-256",
4716                    tables: &["tasks"],
4717                }),
4718            )
4719            .expect("successful owned read");
4720        assert!(client
4721            .diagnostics_snapshot(&Default::default())
4722            .expect("cleared diagnostics")
4723            .query_failures
4724            .iter()
4725            .all(|entry| entry.id != "query-256"));
4726    }
4727
4728    #[test]
4729    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
4730        let mut client = client();
4731        let image = Connection::open_in_memory().unwrap();
4732        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
4733            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4734            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
4735            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4736            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
4737        let table = client.schema.table("tasks").unwrap().clone();
4738        let scopes = vec![("project_id".into(), vec!["p1".into()])];
4739        assert!(client
4740            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
4741            .is_err());
4742        assert!(client.conn.is_autocommit());
4743        assert_eq!(
4744            client
4745                .query("SELECT count(*) AS n FROM tasks", &[])
4746                .unwrap()[0]["n"],
4747            1024
4748        );
4749        assert_eq!(client.drain_change_batches().len(), 1);
4750        image
4751            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
4752            .unwrap();
4753        assert!(matches!(
4754            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
4755            Ok(2050)
4756        ));
4757        assert_eq!(
4758            client
4759                .query("SELECT count(*) AS n FROM tasks", &[])
4760                .unwrap()[0]["n"],
4761            2050
4762        );
4763        assert_eq!(client.drain_change_batches().len(), 3);
4764    }
4765
4766    #[test]
4767    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
4768        let mut client = client();
4769        let base = WindowBase {
4770            table: "tasks".into(),
4771            variable: "project_id".into(),
4772            fixed_scopes: Vec::new(),
4773            params: None,
4774        };
4775        client
4776            .set_window(&base, &["p1".into(), "p2".into()])
4777            .unwrap();
4778        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4779            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4780            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
4781            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4782            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4783        client.drain_change_batches();
4784        assert!(client.set_window(&base, &["p2".into()]).is_err());
4785        assert!(client.conn.is_autocommit());
4786        assert_eq!(
4787            client
4788                .query("SELECT count(*) AS n FROM tasks", &[])
4789                .unwrap()[0]["n"],
4790            1027
4791        );
4792        assert_eq!(client.window_state(&base).units, vec!["p2"]);
4793        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
4794        assert_eq!(client.drain_change_batches().len(), 1);
4795        client
4796            .conn
4797            .execute_batch("DROP TRIGGER interrupt_evict")
4798            .unwrap();
4799        client.drain_pending_evictions().unwrap();
4800        assert!(client.load_pending_evictions().unwrap().is_empty());
4801        assert_eq!(
4802            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
4803            "held"
4804        );
4805        assert_eq!(client.drain_change_batches().len(), 2);
4806    }
4807
4808    #[test]
4809    fn eviction_marker_failures_roll_back_window_changes() {
4810        let mut client = client();
4811        let base = WindowBase {
4812            table: "tasks".into(),
4813            variable: "project_id".into(),
4814            fixed_scopes: Vec::new(),
4815            params: None,
4816        };
4817        client.set_window(&base, &["p1".into()]).unwrap();
4818        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4819            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4820            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4821            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4822        let revision = client.local_revision();
4823        assert!(client.set_window(&base, &[]).is_err());
4824        assert!(client.conn.is_autocommit());
4825        assert_eq!(client.local_revision(), revision);
4826        assert_eq!(
4827            client
4828                .query("SELECT count(*) AS n FROM tasks", &[])
4829                .unwrap()[0]["n"],
4830            2050
4831        );
4832        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4833        client.conn.execute_batch("DROP TRIGGER fail_marker;
4834            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4835        assert!(client.set_window(&base, &[]).is_err());
4836        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4837            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4838        assert!(client.drain_pending_evictions().is_err());
4839        assert!(client.conn.is_autocommit());
4840        assert_eq!(
4841            client
4842                .query("SELECT count(*) AS n FROM tasks", &[])
4843                .unwrap()[0]["n"],
4844            2
4845        );
4846        assert!(client.set_window(&base, &["p1".into()]).is_err());
4847        assert!(client.conn.is_autocommit());
4848        assert!(client.window_state(&base).units.is_empty());
4849        assert!(client.subs.is_empty());
4850        assert_eq!(
4851            client
4852                .query(
4853                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4854                    &[]
4855                )
4856                .unwrap()[0]["n"],
4857            1
4858        );
4859        client
4860            .conn
4861            .execute_batch("DROP TRIGGER fail_marker")
4862            .unwrap();
4863        client.set_window(&base, &["p1".into()]).unwrap();
4864        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4865    }
4866
4867    #[test]
4868    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4869        let mut client = client();
4870        client
4871            .conn
4872            .execute(
4873                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4874                [],
4875            )
4876            .unwrap();
4877        assert_eq!(
4878            client.drain_pending_evictions().unwrap_err(),
4879            "sync.local_corrupt: invalid pending eviction scopes"
4880        );
4881        assert!(client.conn.is_autocommit());
4882        assert!(client.enqueue_startup_sync_if_needed().is_err());
4883    }
4884
4885    #[test]
4886    fn image_chunks_reject_null_primary_keys() {
4887        let mut client = client();
4888        let image = Connection::open_in_memory().unwrap();
4889        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4890            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4891            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4892            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4893            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4894        let table = client.schema.table("tasks").unwrap().clone();
4895        assert!(client
4896            .apply_sqlite_image(
4897                &image,
4898                &table,
4899                true,
4900                &[("project_id".into(), vec!["p1".into()])],
4901                1025,
4902                7,
4903                "digest"
4904            )
4905            .is_err());
4906        assert!(client.conn.is_autocommit());
4907        assert_eq!(
4908            client
4909                .query("SELECT count(*) AS n FROM tasks", &[])
4910                .unwrap()[0]["n"],
4911            0
4912        );
4913    }
4914
4915    #[test]
4916    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4917        for images in [false, true] {
4918            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4919                "name":name,"primaryKey":"id","columns":[
4920                    {"name":"id","type":"string","nullable":false},
4921                    {"name":"project_id","type":"string","nullable":false},
4922                    {"name":"title","type":"string","nullable":false}],
4923                "scopes":[{"pattern":"project:{project_id}"}],
4924                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4925                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4926            })))});
4927            let mut actual =
4928                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4929            let mut reference =
4930                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4931            let scopes = vec![("project_id".into(), vec!["p1".into()])];
4932            for client in [&mut actual, &mut reference] {
4933                client
4934                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4935                    .unwrap();
4936                client.subs[0].effective = Some(scopes.clone());
4937                for (table, id, title) in [
4938                    ("tasks", "a", "original"),
4939                    ("tasks", "b", "occupied"),
4940                    ("tasks", "c", "deleted"),
4941                    ("notes", "n", "untouched"),
4942                ] {
4943                    client
4944                        .write_base_row(
4945                            table,
4946                            &vec![
4947                                Some(ColumnValue::String(id.into())),
4948                                Some(ColumnValue::String("p1".into())),
4949                                Some(ColumnValue::String(title.into())),
4950                            ],
4951                            1,
4952                        )
4953                        .unwrap();
4954                }
4955                client.rebuild_overlay_if_dirty();
4956                client
4957                    .mutate(vec![
4958                        Mutation::Upsert {
4959                            table: "tasks".into(),
4960                            values: Map::from_iter([
4961                                ("id".into(), Value::from("a")),
4962                                ("project_id".into(), Value::from("p1")),
4963                                ("title".into(), Value::from("available")),
4964                            ]),
4965                            base_version: None,
4966                        },
4967                        Mutation::Delete {
4968                            table: "tasks".into(),
4969                            row_id: "c".into(),
4970                            base_version: None,
4971                        },
4972                        Mutation::Upsert {
4973                            table: "tasks".into(),
4974                            values: Map::from_iter([
4975                                ("id".into(), Value::from("local")),
4976                                ("project_id".into(), Value::from("p1")),
4977                                ("title".into(), Value::from("local value")),
4978                            ]),
4979                            base_version: None,
4980                        },
4981                        Mutation::Upsert {
4982                            table: "notes".into(),
4983                            values: Map::from_iter([
4984                                ("id".into(), Value::from("n")),
4985                                ("project_id".into(), Value::from("p1")),
4986                                ("title".into(), Value::from("pending note")),
4987                            ]),
4988                            base_version: None,
4989                        },
4990                    ])
4991                    .unwrap();
4992            }
4993            let pending = actual.pending_commit_ids();
4994            actual.overlay_rebuild_count.set(0);
4995            for (id, title) in [
4996                ("b", "available"),
4997                ("b", "occupied"),
4998                ("a", "server original"),
4999                ("d", "local value"),
5000                ("d", "released"),
5001                ("c", "redelivered"),
5002            ] {
5003                let table = actual.schema.table("tasks").unwrap().clone();
5004                let values = vec![
5005                    Some(ColumnValue::String(id.into())),
5006                    Some(ColumnValue::String("p1".into())),
5007                    Some(ColumnValue::String(title.into())),
5008                ];
5009                if images {
5010                    let image = Connection::open_in_memory().unwrap();
5011                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
5012                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
5013                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
5014                    image
5015                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
5016                        .unwrap();
5017                    assert!(matches!(
5018                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
5019                        Ok(1)
5020                    ));
5021                } else {
5022                    let segment = RowsSegment {
5023                        table: "tasks".into(),
5024                        schema_version: 1,
5025                        columns: table.wire_columns.clone(),
5026                        blocks: vec![vec![ssp2::segment::SegmentRow {
5027                            server_version: 2,
5028                            values: values.clone(),
5029                        }]],
5030                    };
5031                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
5032                }
5033                reference.write_base_row("tasks", &values, 2).unwrap();
5034                reference.rebuild_overlay();
5035                for table in ["tasks", "notes"] {
5036                    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")] {
5037                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
5038                    }
5039                }
5040                assert_eq!(actual.pending_commit_ids(), pending);
5041            }
5042            assert_eq!(
5043                actual.overlay_rebuild_count.get(),
5044                0,
5045                "each chunk must leave unrelated tables alone"
5046            );
5047            let before = actual
5048                .query("SELECT * FROM tasks ORDER BY id", &[])
5049                .unwrap();
5050            let fts_before = actual
5051                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
5052                .unwrap();
5053            let revision = actual.local_revision();
5054            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
5055            let table = actual.schema.table("tasks").unwrap().clone();
5056            let segment = RowsSegment {
5057                table: "tasks".into(),
5058                schema_version: 1,
5059                columns: table.wire_columns.clone(),
5060                blocks: vec![vec![ssp2::segment::SegmentRow {
5061                    server_version: 3,
5062                    values: vec![
5063                        Some(ColumnValue::String("b".into())),
5064                        Some(ColumnValue::String("p1".into())),
5065                        Some(ColumnValue::String("fresh".into())),
5066                    ],
5067                }]],
5068            };
5069            assert!(actual.apply_segment(0, &segment, false).is_err());
5070            assert!(actual.conn.is_autocommit());
5071            assert_eq!(
5072                actual
5073                    .query("SELECT * FROM tasks ORDER BY id", &[])
5074                    .unwrap(),
5075                before
5076            );
5077            assert_eq!(
5078                actual
5079                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
5080                    .unwrap(),
5081                fts_before
5082            );
5083            assert_eq!(actual.local_revision(), revision);
5084            assert_eq!(actual.pending_commit_ids(), pending);
5085        }
5086    }
5087
5088    #[test]
5089    fn import_reconciliation_preserves_typed_pending_primary_keys() {
5090        for (kind, a, b) in [
5091            ("string", json!("a"), json!("b")),
5092            ("integer", json!(1), json!(2)),
5093            ("boolean", json!(true), json!(false)),
5094            ("json", json!("1"), json!("2")),
5095        ] {
5096            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}]}]});
5097            for images in [false, true] {
5098                let mut client =
5099                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
5100                let table = client.schema.table("tasks").unwrap().clone();
5101                client
5102                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
5103                    .unwrap();
5104                for (id, title) in [(&a, "original"), (&b, "occupied")] {
5105                    client
5106                        .write_base_row(
5107                            "tasks",
5108                            &vec![
5109                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
5110                                Some(ColumnValue::String(title.into())),
5111                            ],
5112                            1,
5113                        )
5114                        .unwrap();
5115                }
5116                client.rebuild_overlay_if_dirty();
5117                client
5118                    .mutate(vec![Mutation::Upsert {
5119                        table: "tasks".into(),
5120                        values: Map::from_iter([
5121                            ("id".into(), a.clone()),
5122                            ("title".into(), json!("available")),
5123                        ]),
5124                        base_version: None,
5125                    }])
5126                    .unwrap();
5127                client.overlay_rebuild_count.set(0);
5128                for (title, expected) in [("available", "original"), ("occupied", "available")] {
5129                    let values = vec![
5130                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
5131                        Some(ColumnValue::String(title.into())),
5132                    ];
5133                    if images {
5134                        let image = Connection::open_in_memory().unwrap();
5135                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
5136                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
5137                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
5138                        image
5139                            .execute(
5140                                "INSERT INTO tasks VALUES (?1,?2,2)",
5141                                rusqlite::params![RowParam::Cell(&values[0]), title],
5142                            )
5143                            .unwrap();
5144                        assert!(
5145                            matches!(
5146                                client.apply_sqlite_image(
5147                                    &image,
5148                                    &table,
5149                                    false,
5150                                    &[],
5151                                    1,
5152                                    7,
5153                                    "digest"
5154                                ),
5155                                Ok(1)
5156                            ),
5157                            "{kind}, images={images}"
5158                        );
5159                    } else {
5160                        let segment = RowsSegment {
5161                            table: "tasks".into(),
5162                            schema_version: 1,
5163                            columns: table.wire_columns.clone(),
5164                            blocks: vec![vec![ssp2::segment::SegmentRow {
5165                                server_version: 2,
5166                                values,
5167                            }]],
5168                        };
5169                        assert!(
5170                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
5171                            "{kind}"
5172                        );
5173                    }
5174                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
5175                    let actual: String = client
5176                        .conn
5177                        .query_row(
5178                            "SELECT title FROM tasks WHERE id=?1",
5179                            [RowParam::Cell(&key)],
5180                            |row| row.get(0),
5181                        )
5182                        .unwrap();
5183                    assert_eq!(actual, expected, "{kind}, images={images}");
5184                }
5185                assert_eq!(client.overlay_rebuild_count.get(), 0);
5186            }
5187        }
5188    }
5189
5190    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
5191        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
5192            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
5193            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
5194            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
5195        })))});
5196        let mut client =
5197            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
5198        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();
5199        client.rebuild_overlay();
5200        client
5201            .mutate(vec![Mutation::Upsert {
5202                table: "tasks".into(),
5203                values: Map::from_iter([
5204                    ("id".into(), json!("offline")),
5205                    ("project_id".into(), json!("p1")),
5206                    ("title".into(), json!("pending edit")),
5207                ]),
5208                base_version: None,
5209            }])
5210            .unwrap();
5211        let image = Connection::open_in_memory().unwrap();
5212        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
5213            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
5214        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();
5215        image
5216            .execute(
5217                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
5218                [rows],
5219            )
5220            .unwrap();
5221        client.overlay_rebuild_count.set(0);
5222        let table = client.schema.table("tasks").unwrap().clone();
5223        (client, image, table)
5224    }
5225
5226    #[test]
5227    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
5228        let (mut client, image, table) = unique_import_fixture(1);
5229        image
5230            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
5231            .unwrap();
5232        client
5233            .conn
5234            .execute_batch("PRAGMA max_page_count = 1")
5235            .unwrap();
5236        let effective = [("project_id".into(), vec!["p1".into()])];
5237        assert!(client
5238            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
5239            .is_err());
5240        let failure = client.storage_failure.borrow().clone().unwrap();
5241        assert_eq!(failure.code, Some("client.storage_full"));
5242        assert_eq!(failure.sqlite_code, Some(13));
5243        assert!(!failure.message.contains("rollback"));
5244        assert!(client.conn.is_autocommit());
5245        client
5246            .conn
5247            .execute_batch("PRAGMA max_page_count = 1073741823")
5248            .unwrap();
5249        assert!(matches!(
5250            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
5251            Ok(1)
5252        ));
5253        assert!(client.conn.is_autocommit());
5254    }
5255
5256    #[test]
5257    fn large_unique_import_copies_no_unrelated_tables() {
5258        let (mut client, image, table) = unique_import_fixture(10240);
5259        let pending = client.pending_commit_ids();
5260        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
5261            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
5262        assert!(matches!(
5263            client.apply_sqlite_image(
5264                &image,
5265                &table,
5266                true,
5267                &[("project_id".into(), vec!["p1".into()])],
5268                10240,
5269                7,
5270                "digest"
5271            ),
5272            Ok(10240)
5273        ));
5274        assert_eq!(client.overlay_rebuild_count.get(), 0);
5275        assert_eq!(client.pending_commit_ids(), pending);
5276        assert_eq!(
5277            client
5278                .query("SELECT count(*) AS n FROM tasks", &[])
5279                .unwrap()[0]["n"],
5280            10241
5281        );
5282        assert_eq!(
5283            client
5284                .query("SELECT count(*) AS n FROM tasks_fts", &[])
5285                .unwrap()[0]["n"],
5286            10241
5287        );
5288        assert_eq!(
5289            client
5290                .query("SELECT count(*) AS n FROM notes_fts", &[])
5291                .unwrap()[0]["n"],
5292            8192
5293        );
5294    }
5295
5296    #[test]
5297    #[ignore = "manual scaling benchmark; see bench/README.md"]
5298    fn benchmark_unique_image_import() {
5299        let mut samples = Vec::new();
5300        for rows in [10240, 51200, 102400] {
5301            for trial in 0..4 {
5302                let (mut client, image, table) = unique_import_fixture(rows);
5303                let start = std::time::Instant::now();
5304                assert!(
5305                    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)
5306                );
5307                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
5308                assert_eq!(
5309                    client
5310                        .query("SELECT count(*) AS n FROM tasks", &[])
5311                        .unwrap()[0]["n"],
5312                    rows + 1
5313                );
5314                assert_eq!(
5315                    client
5316                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
5317                        .unwrap()[0]["n"],
5318                    rows + 1
5319                );
5320                assert_eq!(
5321                    client
5322                        .query("SELECT count(*) AS n FROM notes_fts", &[])
5323                        .unwrap()[0]["n"],
5324                    8192
5325                );
5326                assert_eq!(client.pending_commit_ids().len(), 1);
5327                if trial > 0 {
5328                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
5329                }
5330            }
5331        }
5332        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});
5333        let output = serde_json::to_string_pretty(&artifact).unwrap();
5334        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
5335            use std::io::Write;
5336            let mut file = std::fs::OpenOptions::new()
5337                .write(true)
5338                .create_new(true)
5339                .open(path)
5340                .unwrap();
5341            file.write_all(output.as_bytes()).unwrap();
5342        } else {
5343            println!("{output}");
5344        }
5345    }
5346
5347    #[test]
5348    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
5349        let schema = json!({
5350            "version": 1,
5351            "tables": [{
5352                "name": "catalogue_codes",
5353                "primaryKey": "id",
5354                "columns": [
5355                    { "name": "id", "type": "string", "nullable": false },
5356                    { "name": "release_id", "type": "string", "nullable": false },
5357                    { "name": "code", "type": "string", "nullable": false },
5358                    { "name": "title", "type": "string", "nullable": false }
5359                ],
5360                "scopes": [{ "pattern": "release:{release_id}" }],
5361                "ftsIndexes": [{
5362                    "name": "catalogue_codes_fts",
5363                    "columns": ["code", "title"],
5364                    "tokenize": "unicode61 remove_diacritics 2"
5365                }]
5366            }]
5367        });
5368        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
5369            .expect("FTS5 client");
5370        let insert_trigger: String = client
5371            .conn
5372            .query_row(
5373                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
5374                [],
5375                |row| row.get(0),
5376            )
5377            .expect("insert trigger");
5378        let replace_guard: String = client
5379            .conn
5380            .query_row(
5381                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
5382                [],
5383                |row| row.get(0),
5384            )
5385            .expect("replace guard");
5386        assert!(!insert_trigger.contains("DELETE FROM"));
5387        assert!(replace_guard.contains("BEFORE INSERT"));
5388        assert!(replace_guard.contains("WHEN EXISTS"));
5389        let search = |client: &SyncClient, query: &str| {
5390            client
5391                .query(
5392                    "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",
5393                    &[Value::from(query)],
5394                )
5395                .expect("FTS query")
5396        };
5397
5398        client
5399            .mutate(vec![Mutation::Upsert {
5400                table: "catalogue_codes".to_owned(),
5401                values: Map::from_iter([
5402                    ("id".to_owned(), Value::from("c1")),
5403                    ("release_id".to_owned(), Value::from("r1")),
5404                    ("code".to_owned(), Value::from("A01")),
5405                    ("title".to_owned(), Value::from("Cholera")),
5406                ]),
5407                base_version: None,
5408            }])
5409            .expect("insert code");
5410        assert_eq!(search(&client, "cholera").len(), 1);
5411        let prior = client
5412            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
5413            .unwrap();
5414        client
5415            .conn
5416            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
5417            .unwrap();
5418        client.create_synced_tables().unwrap();
5419        assert_eq!(
5420            client
5421                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
5422                .unwrap(),
5423            prior
5424        );
5425        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();
5426        let rendered = serde_json::to_string(&plan).unwrap();
5427        assert!(rendered.contains("COVERING INDEX"));
5428        assert!(rendered.contains("INDEX 0:="));
5429        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
5430        assert_eq!(search(&client, "cholera").len(), 1);
5431
5432        client
5433            .mutate(vec![Mutation::Upsert {
5434                table: "catalogue_codes".to_owned(),
5435                values: Map::from_iter([
5436                    ("id".to_owned(), Value::from("c1")),
5437                    ("release_id".to_owned(), Value::from("r1")),
5438                    ("code".to_owned(), Value::from("A01")),
5439                    ("title".to_owned(), Value::from("Enteric infection")),
5440                ]),
5441                base_version: None,
5442            }])
5443            .expect("update code");
5444        assert!(search(&client, "cholera").is_empty());
5445        assert_eq!(search(&client, "enteric").len(), 1);
5446
5447        client
5448            .mutate(vec![Mutation::Delete {
5449                table: "catalogue_codes".to_owned(),
5450                row_id: "c1".to_owned(),
5451                base_version: None,
5452            }])
5453            .expect("delete code");
5454        assert!(search(&client, "enteric").is_empty());
5455    }
5456
5457    #[test]
5458    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
5459        let schema = json!({
5460            "version": 1,
5461            "tables": [{
5462                "name": "patient_notes",
5463                "primaryKey": "id",
5464                "columns": [
5465                    { "name": "id", "type": "string", "nullable": false },
5466                    { "name": "practice_id", "type": "string", "nullable": false },
5467                    { "name": "encryption_key_id", "type": "string", "nullable": false },
5468                    { "name": "title", "type": "string", "nullable": false }
5469                ],
5470                "scopes": [{ "pattern": "practice:{practice_id}" }],
5471                "ftsIndexes": [{
5472                    "name": "patient_notes_fts",
5473                    "columns": ["title"],
5474                    "tokenize": "unicode61 remove_diacritics 2"
5475                }]
5476            }]
5477        });
5478        let mut client = SyncClient::new(
5479            "local-purge-test".to_owned(),
5480            &schema,
5481            ClientLimits::default(),
5482        )
5483        .expect("local purge client");
5484        client
5485            .conn
5486            .execute(
5487                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
5488                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
5489                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
5490                [],
5491            )
5492            .expect("seed base rows");
5493        client.overlay_dirty.set(true);
5494        client.rebuild_overlay_if_dirty();
5495
5496        let note = |id: &str, key_id: &str, title: &str| {
5497            Map::from_iter([
5498                ("id".to_owned(), Value::from(id)),
5499                ("practice_id".to_owned(), Value::from("practice-1")),
5500                ("encryption_key_id".to_owned(), Value::from(key_id)),
5501                ("title".to_owned(), Value::from(title)),
5502            ])
5503        };
5504        let doomed = client
5505            .mutate(vec![
5506                Mutation::Upsert {
5507                    table: "patient_notes".to_owned(),
5508                    values: note("target", "key-revoked", "Target changed"),
5509                    base_version: None,
5510                },
5511                Mutation::Upsert {
5512                    table: "patient_notes".to_owned(),
5513                    values: note("unrelated", "key-held", "Unrelated changed"),
5514                    base_version: None,
5515                },
5516            ])
5517            .expect("doomed commit");
5518        let kept = client
5519            .mutate(vec![Mutation::Upsert {
5520                table: "patient_notes".to_owned(),
5521                values: note("kept", "key-held", "Kept optimistic"),
5522                base_version: None,
5523            }])
5524            .expect("kept commit");
5525        client.drain_change_batches();
5526
5527        let input = LocalDataPurgeInput {
5528            purge_id: "purge-001".to_owned(),
5529            targets: vec![LocalDataPurgeTarget {
5530                table: "patient_notes".to_owned(),
5531                selectors: BTreeMap::from([(
5532                    "encryption_key_id".to_owned(),
5533                    vec!["key-revoked".to_owned()],
5534                )]),
5535            }],
5536        };
5537        assert_eq!(
5538            client.purge_local_data(&input).expect("apply purge"),
5539            LocalDataPurgeResult {
5540                already_applied: false,
5541                purged_rows: 1,
5542                dropped_commits: 1,
5543            }
5544        );
5545        let rows = client
5546            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
5547            .expect("visible rows");
5548        assert_eq!(rows.len(), 2);
5549        assert_eq!(rows[0]["id"], "kept");
5550        assert_eq!(rows[1]["id"], "unrelated");
5551        assert_eq!(rows[1]["title"], "Unrelated original");
5552        let fts = client
5553            .query(
5554                "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'",
5555                &[],
5556            )
5557            .expect("fts query");
5558        assert!(fts.is_empty());
5559        assert_eq!(client.outbox.len(), 1);
5560        assert_eq!(client.outbox[0].client_commit_id, kept);
5561        let outcome = client
5562            .commit_outcome(&doomed)
5563            .expect("read doomed outcome")
5564            .expect("doomed outcome");
5565        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
5566        match &outcome.results[0] {
5567            CommitOperationOutcome::Error { rejection } => {
5568                assert_eq!(rejection.code, "client.local_data_purged");
5569                assert_eq!(rejection.client_commit_id, doomed);
5570            }
5571            other => panic!("expected local purge rejection, got {other:?}"),
5572        }
5573        assert_eq!(client.drain_change_batches().len(), 1);
5574        assert_eq!(
5575            client.purge_local_data(&input).expect("retry purge"),
5576            LocalDataPurgeResult {
5577                already_applied: true,
5578                purged_rows: 0,
5579                dropped_commits: 0,
5580            }
5581        );
5582        let conflicting = LocalDataPurgeInput {
5583            purge_id: input.purge_id.clone(),
5584            targets: vec![LocalDataPurgeTarget {
5585                table: "patient_notes".to_owned(),
5586                selectors: BTreeMap::from([(
5587                    "encryption_key_id".to_owned(),
5588                    vec!["key-held".to_owned()],
5589                )]),
5590            }],
5591        };
5592        assert!(client
5593            .purge_local_data(&conflicting)
5594            .expect_err("id collision must fail")
5595            .contains("already used with a different plan"));
5596    }
5597}
5598
5599impl SubState {
5600    fn name(self) -> &'static str {
5601        match self {
5602            SubState::Active => "active",
5603            SubState::Revoked => "revoked",
5604            SubState::Failed => "failed",
5605        }
5606    }
5607
5608    fn parse(value: &str) -> Self {
5609        match value {
5610            "revoked" => Self::Revoked,
5611            "failed" => Self::Failed,
5612            _ => Self::Active,
5613        }
5614    }
5615}
5616
5617#[derive(Debug, Clone)]
5618struct Subscription {
5619    id: String,
5620    table: String,
5621    requested: Vec<(String, Vec<String>)>,
5622    params: Option<String>,
5623    cursor: i64,
5624    /// §4.7 resume token, round-tripped opaquely.
5625    bootstrap_state: Option<String>,
5626    state: SubState,
5627    reason_code: Option<String>,
5628    /// Last effective scopes echoed while active (§3.3: persisted for the
5629    /// purge contract; each active echo replaces it).
5630    effective: Option<Vec<(String, Vec<String>)>>,
5631    synced_once: bool,
5632}
5633
5634#[derive(Debug, Clone)]
5635struct OutboxOp {
5636    upsert: bool,
5637    table: String,
5638    row_id: String,
5639    base_version: Option<i64>,
5640    /// Schema-agnostic local form (§0): driver JSON values, encoded with
5641    /// the current codec at send time. A key's presence IS the §6.1 sparse
5642    /// presence set — both the overlay and the wire payload derive from it.
5643    values: Option<Map<String, Value>>,
5644}
5645
5646impl From<&OutboxOp> for CommitOperation {
5647    fn from(operation: &OutboxOp) -> Self {
5648        Self {
5649            table: operation.table.clone(),
5650            row_id: operation.row_id.clone(),
5651            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
5652            base_version: operation.base_version,
5653            values: operation.values.clone(),
5654        }
5655    }
5656}
5657
5658#[derive(Debug, Clone)]
5659struct OutboxCommit {
5660    client_commit_id: String,
5661    ops: Vec<OutboxOp>,
5662}
5663
5664/// One commit's `(table, value keys)` per upsert operation — the classification
5665/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
5666/// can never disagree about which commits cannot re-encode.
5667fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
5668    commit
5669        .ops
5670        .iter()
5671        .filter(|operation| operation.upsert)
5672        .map(|operation| {
5673            (
5674                operation.table.as_str(),
5675                operation
5676                    .values
5677                    .as_ref()
5678                    .map(|values| values.keys().map(String::as_str).collect())
5679                    .unwrap_or_default(),
5680            )
5681        })
5682        .collect()
5683}
5684
5685type FailedCommit = (OutboxCommit, Map<String, Value>);
5686
5687#[derive(Debug, Clone)]
5688struct CompiledLocalDataPurgeTarget {
5689    table: String,
5690    selectors: Vec<(String, Vec<String>)>,
5691}
5692
5693struct StoredCommitOutcomeRow {
5694    sequence: i64,
5695    client_commit_id: String,
5696    status: String,
5697    recorded_at_ms: i64,
5698    results_json: String,
5699    operations_json: Option<String>,
5700    resolution: String,
5701    resolved_at_ms: Option<i64>,
5702    replacement_client_commit_id: Option<String>,
5703}
5704
5705/// Section outcome distinguishing the §5.6 subscription-local fail-closed
5706/// path from a round-aborting failure (§1.4 rule 5).
5707enum SectionError {
5708    FailClosed,
5709    Abort(String, String),
5710}
5711
5712pub(crate) struct RequestMeta {
5713    pushed_ids: Vec<String>,
5714    /// Subscription id → the request carried `cursor < 0` and no resume
5715    /// token (§5.6 first-page detection: a *fresh* bootstrap).
5716    fresh: Vec<(String, bool)>,
5717    pub(crate) accept: u8,
5718    /// §6.1 splitBatch: outbox commits held back from THIS request because
5719    /// the running operation count reached the push cap — the next round
5720    /// pushes them (`sync_needed` stays set while any remain).
5721    deferred_commits: usize,
5722}
5723
5724#[derive(Default)]
5725struct ChangeAccumulator {
5726    /// None means table-wide; Some is the exact scoped domain.
5727    tables: BTreeMap<String, Option<BTreeSet<String>>>,
5728    windows: BTreeMap<(String, String), BTreeSet<String>>,
5729    status: bool,
5730    conflicts: bool,
5731    rejections: bool,
5732    outcomes: bool,
5733}
5734
5735impl ChangeAccumulator {
5736    fn table(&mut self, table: &str) {
5737        self.tables.insert(table.to_owned(), None);
5738    }
5739
5740    fn scope(&mut self, table: &str, key: String) {
5741        match self.tables.get_mut(table) {
5742            Some(None) => {}
5743            Some(Some(keys)) => {
5744                keys.insert(key);
5745            }
5746            None => {
5747                self.tables
5748                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
5749            }
5750        }
5751    }
5752
5753    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
5754        self.windows
5755            .entry((base_key.to_owned(), table.to_owned()))
5756            .or_default()
5757            .insert(unit.to_owned());
5758    }
5759
5760    fn touched(&self) -> bool {
5761        !self.tables.is_empty()
5762            || !self.windows.is_empty()
5763            || self.status
5764            || self.conflicts
5765            || self.rejections
5766            || self.outcomes
5767    }
5768}
5769
5770/// §6.1: the server caps total operations per request (reference default
5771/// 500) and rejects the whole batch with `sync.too_many_operations`; the
5772/// client "splits and retries". Splitting happens at build time: commits are
5773/// included IN ORDER until the operation budget is spent, the rest wait for
5774/// the next round.
5775const PUSH_OPS_PER_REQUEST: usize = 500;
5776const MAX_LOCAL_PURGE_TARGETS: usize = 64;
5777const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
5778const MAX_LOCAL_PURGE_VALUES: usize = 128;
5779const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
5780pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
5781
5782/// Dirty visible tables; `None` means a whole-replica recovery/reset.
5783struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
5784
5785impl OverlayDirty {
5786    fn clean() -> Self {
5787        Self(RefCell::new(Some(BTreeSet::new())))
5788    }
5789    fn get(&self) -> bool {
5790        self.0
5791            .borrow()
5792            .as_ref()
5793            .is_none_or(|tables| !tables.is_empty())
5794    }
5795    fn set(&self, dirty: bool) {
5796        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
5797    }
5798    fn table(&self, table: &str) {
5799        if let Some(tables) = self.0.borrow_mut().as_mut() {
5800            tables.insert(table.to_owned());
5801        }
5802    }
5803    fn snapshot(&self) -> Option<BTreeSet<String>> {
5804        self.0.borrow().clone()
5805    }
5806    fn restore(&self, tables: Option<BTreeSet<String>>) {
5807        *self.0.borrow_mut() = tables;
5808    }
5809}
5810
5811pub struct SyncClient {
5812    #[cfg(feature = "bench-internals")]
5813    benchmark_phases: Recorder,
5814    conn: Connection,
5815    schema: ClientSchema,
5816    client_id: String,
5817    limits: ClientLimits,
5818    subs: Vec<Subscription>,
5819    outbox: Vec<OutboxCommit>,
5820    failed_commits: Vec<FailedCommit>,
5821    retain_failed_commits: bool,
5822    conflicts: Vec<ConflictRecord>,
5823    rejections: Vec<RejectionRecord>,
5824    schema_floor: Option<SchemaFloor>,
5825    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
5826    lease_state: Option<LeaseState>,
5827    /// §1.6: the schema-floor response stops syncing until an upgrade.
5828    stopped: bool,
5829    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
5830    upgrading: bool,
5831    /// §8.4 coalesced sync-needed signal.
5832    sync_needed: bool,
5833    /// §8.8: how sync rounds treat the realtime binding, and its exact
5834    /// availability state with the evidence behind a lost or refused one.
5835    realtime_policy: RealtimePolicy,
5836    realtime_state: RealtimeState,
5837    realtime_reason_code: Option<String>,
5838    realtime_retry_delay_ms: Option<u64>,
5839    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
5840    presence: HashMap<String, HashMap<String, PresencePeer>>,
5841    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
5842    /// host may pin it (conformance runs on a virtual clock).
5843    now_ms: Option<i64>,
5844    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
5845    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
5846    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
5847    encryption: crate::values::EncryptionConfig,
5848    /// Fail-closed host bootstrap gate. While set, command hosts permit only
5849    /// status/lifecycle inspection and an exact authorized local purge.
5850    security_preflight: bool,
5851    transport_enabled: bool,
5852    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
5853    /// means the feature is off; the descriptor write and the container orphan
5854    /// sweep still happen.
5855    previous_version: Option<PreviousVersionContextConfig>,
5856    /// Per-table primary-key upsert SQL, built once per (full table name) —
5857    /// the row write path runs per row during bootstrap (§5.6), so the SQL
5858    /// string (and, via `prepare_cached`, its compiled statement) is reused
5859    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
5860    /// schema reset (the column lists may have changed).
5861    insert_sql: RefCell<HashMap<String, String>>,
5862    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
5863    /// reset and restart explicitly invalidate every table.
5864    overlay_dirty: OverlayDirty,
5865    // First SQLite failure of the current operation, before savepoint cleanup.
5866    storage_failure: RefCell<Option<QueryReadFailure>>,
5867    /// Test-only structural performance signal: response processing must not
5868    /// turn a batch of acknowledgements into one full overlay rebuild each.
5869    #[cfg(test)]
5870    overlay_rebuild_count: Cell<usize>,
5871    #[cfg(test)]
5872    acknowledged_replay_count: Cell<usize>,
5873    /// Test-only structural performance signal: outcome retention is enforced
5874    /// once per response rather than once per acknowledgement.
5875    #[cfg(test)]
5876    outcome_prune_count: Cell<usize>,
5877    /// Exact observer-transaction output drained by command/FFI hosts.
5878    progress: ProgressObserver,
5879    change_queue: VecDeque<ClientChangeBatch>,
5880    sync_intent_queue: VecDeque<SyncIntent>,
5881    /// Explicit exponential retry policy for transient transport failures.
5882    retry_delay_ms: u64,
5883    /// §7.6: the background retry the current round scheduled, if any.
5884    round_retry_delay_ms: Option<u64>,
5885    active_round: Option<uuid::Uuid>,
5886    last_round: Option<DiagnosticLastRound>,
5887    last_change: Option<DiagnosticLastChange>,
5888    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
5889    query_failures: Vec<DiagnosticQueryFailure>,
5890}
5891
5892pub(crate) fn quote_ident(name: &str) -> String {
5893    format!("\"{}\"", name.replace('"', "\"\""))
5894}
5895/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
5896/// previous-version module reads and writes the SAME records as the client
5897/// instead of growing a parallel meta layer.
5898pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5899    conn.query_row(
5900        "SELECT value FROM _syncular_meta WHERE key = ?1",
5901        rusqlite::params![key],
5902        |row| row.get::<_, String>(0),
5903    )
5904    .ok()
5905}
5906
5907pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5908    let _ = conn.execute(
5909        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5910        rusqlite::params![key, value],
5911    );
5912}
5913
5914pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5915    let _ = conn.execute(
5916        "DELETE FROM _syncular_meta WHERE key = ?1",
5917        rusqlite::params![key],
5918    );
5919}
5920
5921/// Seek by the existing key while retaining the exact legacy text match.
5922fn row_id_predicate(table: &TableSchema) -> String {
5923    let key = quote_ident(&table.primary_key);
5924    let text_match = format!("CAST({key} AS TEXT) = ?1");
5925    match table.columns[table.pk_index].ty {
5926        ColumnType::String | ColumnType::Json => {
5927            format!("{key} = ?1 AND {text_match}")
5928        }
5929        // Native columns have no declared SQLite affinity. Match the stored
5930        // integer bind type explicitly. Unary + removes expression affinity so
5931        // SQLite can seek the typeless key; the text check preserves exact IDs.
5932        ColumnType::Integer | ColumnType::Boolean => {
5933            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5934        }
5935        // REAL-to-text conversion can round distinct values to the same text.
5936        // Narrowing those matches by numeric equality would change behavior.
5937        _ => text_match,
5938    }
5939}
5940
5941fn is_local_operation_code_like(value: &str) -> bool {
5942    let bytes = value.as_bytes();
5943    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5944        && bytes
5945            .iter()
5946            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5947}
5948
5949fn base_table(name: &str) -> String {
5950    quote_ident(&format!("_syncular_base_{name}"))
5951}
5952
5953/// §4.8: a stable, server-opaque key for a window base — table + variable +
5954/// canonical fixed scopes. Two `set_window` calls with the same base
5955/// address the same registry rows.
5956/// §4.8 deferred eviction record: (sub id, table, effective scope map).
5957type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5958
5959fn window_base_key(base: &WindowBase) -> String {
5960    format!(
5961        "{}\0{}\0{}",
5962        base.table,
5963        base.variable,
5964        canonical_scope_json(&base.fixed_scopes)
5965    )
5966}
5967
5968/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
5969fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5970    let mut scopes = base.fixed_scopes.clone();
5971    scopes.retain(|(k, _)| k != &base.variable);
5972    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5973    scopes
5974}
5975
5976/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
5977/// echoed not interpreted by the server, so the exact hash is client
5978/// convention; SHA-256 matches the SPEC's worked example.
5979fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5980    let canonical = canonical_scope_json(&unit_scopes(base, unit));
5981    let digest = Sha256::digest(canonical.as_bytes());
5982    let hex = bytes_to_hex(&digest);
5983    format!("w:{}:{}", base.table, &hex[..16])
5984}
5985
5986fn visible_table(name: &str) -> String {
5987    quote_ident(name)
5988}
5989
5990const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5991
5992/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
5993/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
5994fn is_synced_table_name(name: &str) -> bool {
5995    if name.starts_with("sqlite_") {
5996        return false;
5997    }
5998    if name.starts_with("_syncular_base_") {
5999        return true; // the base half of a synced table pair
6000    }
6001    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
6002    !name.starts_with("_syncular_")
6003}
6004
6005/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
6006fn blob_id_for(bytes: &[u8]) -> String {
6007    let digest = Sha256::digest(bytes);
6008    format!("sha256:{}", bytes_to_hex(&digest))
6009}
6010
6011/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
6012/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
6013/// per row on the §5.6 bootstrap path), scalars bind owned.
6014enum RowParam<'a> {
6015    Cell(&'a Option<ColumnValue>),
6016    Version(i64),
6017}
6018
6019impl rusqlite::ToSql for RowParam<'_> {
6020    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
6021        Ok(match self {
6022            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
6023            RowParam::Cell(cell) => match cell {
6024                None => ToSqlOutput::Owned(SqlValue::Null),
6025                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
6026                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
6027                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
6028                Some(ColumnValue::Boolean(b)) => {
6029                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
6030                }
6031                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
6032                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
6033                }
6034                // §5.10: crdt bytes store as BLOB, like bytes.
6035                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
6036                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
6037                }
6038            },
6039        })
6040    }
6041}
6042
6043// Retain a typed primary key across the current import chunk without copying row payloads.
6044fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
6045    match param.to_sql().map_err(|error| error.to_string())? {
6046        ToSqlOutput::Owned(value) => Ok(value),
6047        ToSqlOutput::Borrowed(value) => {
6048            SqlValue::try_from(value).map_err(|error| error.to_string())
6049        }
6050        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
6051    }
6052}
6053
6054/// §5.3 image cell → bind parameter, strict per the declared column type
6055/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
6056/// nullable). Mismatches are image-producer violations. Returns the cell
6057/// borrowed when the stored representation already matches what the row
6058/// codec would write, or the normalized scalar (boolean → 0/1, float from
6059/// INTEGER → REAL) otherwise — the local write is byte-identical to the
6060/// old convert-then-insert path without allocating per cell.
6061fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
6062    use ssp2::segment::ColumnType;
6063    let mismatch = || {
6064        Err(format!(
6065            "image column {:?} holds a value of the wrong type",
6066            column.name
6067        ))
6068    };
6069    match value {
6070        ValueRef::Null => {
6071            if !column.nullable {
6072                return Err(format!(
6073                    "image column {:?} is NULL but not nullable",
6074                    column.name
6075                ));
6076            }
6077            Ok(ToSqlOutput::Owned(SqlValue::Null))
6078        }
6079        ValueRef::Integer(i) => match column.ty {
6080            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
6081            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
6082            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
6083            _ => mismatch(),
6084        },
6085        ValueRef::Real(_) => match column.ty {
6086            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
6087            _ => mismatch(),
6088        },
6089        ValueRef::Text(t) => {
6090            std::str::from_utf8(t)
6091                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
6092            match column.ty {
6093                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
6094                    Ok(ToSqlOutput::Borrowed(value))
6095                }
6096                _ => mismatch(),
6097            }
6098        }
6099        ValueRef::Blob(_) => match column.ty {
6100            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
6101            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
6102            _ => mismatch(),
6103        },
6104    }
6105}
6106
6107pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
6108    use rusqlite::types::ValueRef;
6109    match value {
6110        ValueRef::Null => Value::Null,
6111        ValueRef::Integer(i) => match column.ty {
6112            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
6113            ssp2::segment::ColumnType::Float => {
6114                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
6115            }
6116            _ => Value::from(i),
6117        },
6118        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
6119        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
6120        ValueRef::Blob(b) => {
6121            let mut map = Map::new();
6122            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
6123            Value::Object(map)
6124        }
6125    }
6126}
6127
6128/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
6129/// Objects are accepted in lossless `{"$bytes": hex}` and
6130/// `{"$bigint": decimal}` envelope forms.
6131fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
6132    Ok(match value {
6133        Value::Null => SqlValue::Null,
6134        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
6135        Value::Number(n) => {
6136            if let Some(i) = n.as_i64() {
6137                SqlValue::Integer(i)
6138            } else if let Some(f) = n.as_f64() {
6139                SqlValue::Real(f)
6140            } else {
6141                return Err(format!("query param number {n} is out of range"));
6142            }
6143        }
6144        Value::String(s) => SqlValue::Text(s.clone()),
6145        Value::Object(_) => {
6146            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
6147                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
6148            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
6149                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
6150                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
6151                })?)
6152            } else {
6153                return Err(
6154                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
6155                        .to_owned(),
6156                );
6157            }
6158        }
6159        Value::Array(_) => return Err("query array params are not supported".to_owned()),
6160    })
6161}
6162
6163/// Map a rusqlite value with no schema column to consult (arbitrary query
6164/// output): integers/reals/text pass through by stored affinity, blobs ride
6165/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
6166/// schema column type to recover booleans/floats/json.
6167fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
6168    use rusqlite::types::ValueRef;
6169    match value {
6170        ValueRef::Null => Value::Null,
6171        ValueRef::Integer(i) => {
6172            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
6173            // ordinary UI-sized integers ergonomic and envelope only values
6174            // beyond JavaScript's safe range.
6175            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
6176            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
6177                Value::from(i)
6178            } else {
6179                let mut map = Map::new();
6180                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
6181                Value::Object(map)
6182            }
6183        }
6184        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
6185        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
6186        ValueRef::Blob(b) => {
6187            let mut map = Map::new();
6188            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
6189            Value::Object(map)
6190        }
6191    }
6192}
6193
6194/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
6195fn query_connection(
6196    conn: &Connection,
6197    sql: &str,
6198    params: &[QueryValue],
6199) -> Result<Vec<QueryRow>, QueryReadFailure> {
6200    crate::query_guard::assert_read_only_query(sql)?;
6201    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
6202    let bound: Vec<SqlValue> = params
6203        .iter()
6204        .map(json_param_to_sql)
6205        .collect::<Result<_, _>>()?;
6206    let mut stmt = conn.prepare(&lowered_sql)?;
6207    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
6208    let bound_refs: Vec<&dyn rusqlite::ToSql> =
6209        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
6210    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
6211    let mut out = Vec::new();
6212    while let Some(row) = sql_rows.next()? {
6213        let mut record = Map::new();
6214        for (i, name) in column_names.iter().enumerate() {
6215            let value = row.get_ref(i)?;
6216            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
6217        }
6218        out.push(record);
6219    }
6220    Ok(out)
6221}
6222
6223fn persisted_window_state(
6224    conn: &Connection,
6225    base: &WindowBase,
6226) -> Result<WindowState, QueryReadFailure> {
6227    let mut stmt = conn.prepare(
6228        "SELECT windows.unit, subscriptions.state_json
6229           FROM _syncular_windows AS windows
6230           JOIN _syncular_subscriptions AS subscriptions
6231             ON subscriptions.id = windows.sub_id
6232          WHERE windows.base = ?1
6233          ORDER BY windows.unit ASC",
6234    )?;
6235    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
6236        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6237    })?;
6238    let mut units = Vec::new();
6239    let mut pending = Vec::new();
6240    for row in rows {
6241        let (unit, raw) = row?;
6242        let state: Value = serde_json::from_str(&raw)
6243            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
6244        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
6245            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
6246            || state
6247                .get("bootstrapState")
6248                .is_some_and(|value| !value.is_null());
6249        if is_pending {
6250            pending.push(unit.clone());
6251        }
6252        units.push(unit);
6253    }
6254    Ok(WindowState { units, pending })
6255}
6256
6257fn snapshot_connection(
6258    conn: &Connection,
6259    sql: &str,
6260    params: &[Value],
6261    coverage: &[WindowCoverage],
6262) -> Result<QuerySnapshot, QueryReadFailure> {
6263    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
6264    let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
6265        let revision = conn
6266            .query_row(
6267                "SELECT value FROM _syncular_meta WHERE key = ?1",
6268                rusqlite::params![LOCAL_REVISION_KEY],
6269                |row| row.get::<_, String>(0),
6270            )
6271            .ok()
6272            .and_then(|value| value.parse::<u64>().ok())
6273            .unwrap_or(0);
6274        let rows = query_connection(conn, sql, params)?;
6275        let mut pending = Vec::new();
6276        let mut missing = Vec::new();
6277        for requested in coverage {
6278            let base_key = window_base_key(&requested.base);
6279            let state = persisted_window_state(conn, &requested.base)?;
6280            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
6281                let reference = WindowUnitRef {
6282                    base_key: base_key.clone(),
6283                    unit: unit.clone(),
6284                };
6285                if !state.units.iter().any(|held| held == &unit) {
6286                    missing.push(reference);
6287                } else if state.pending.iter().any(|held| held == &unit) {
6288                    pending.push(reference);
6289                }
6290            }
6291        }
6292        Ok(QuerySnapshot {
6293            revision: revision.to_string(),
6294            rows,
6295            coverage: CoverageSnapshot {
6296                complete: pending.is_empty() && missing.is_empty(),
6297                pending,
6298                missing,
6299            },
6300        })
6301    })();
6302    match result {
6303        Ok(snapshot) => {
6304            conn.execute_batch("RELEASE syncular_snapshot_read")?;
6305            Ok(snapshot)
6306        }
6307        Err(mut error) => {
6308            if let Err(rollback) = conn
6309                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
6310            {
6311                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
6312            }
6313            Err(error)
6314        }
6315    }
6316}
6317
6318/// A long-lived read-only SQLite sidecar for latency-critical native views.
6319/// Network rounds stay serialized on the mutable core owner, while atomic
6320/// query snapshots use this independent connection and therefore never queue
6321/// behind HTTP/WebSocket latency.
6322pub struct FileQuerySnapshotReader {
6323    path: String,
6324    conn: Option<Connection>,
6325}
6326
6327impl FileQuerySnapshotReader {
6328    #[must_use]
6329    pub fn new(path: impl Into<String>) -> Self {
6330        Self {
6331            path: path.into(),
6332            conn: None,
6333        }
6334    }
6335
6336    fn connection(&mut self) -> Result<&Connection, String> {
6337        if self.conn.is_none() {
6338            let conn = Connection::open_with_flags(
6339                &self.path,
6340                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
6341            )
6342            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
6343            conn.busy_timeout(std::time::Duration::from_millis(250))
6344                .map_err(|error| error.to_string())?;
6345            self.conn = Some(conn);
6346        }
6347        self.conn
6348            .as_ref()
6349            .ok_or_else(|| "read sidecar connection missing".to_owned())
6350    }
6351
6352    /// Hosts report an owned failure, and the first success after it, to the
6353    /// owning core through [`SyncClient::record_query_read`] (§7.6).
6354    pub fn query_snapshot(
6355        &mut self,
6356        sql: &str,
6357        params: &[Value],
6358        coverage: &[WindowCoverage],
6359    ) -> Result<QuerySnapshot, QueryReadFailure> {
6360        snapshot_connection(self.connection()?, sql, params, coverage)
6361    }
6362}
6363
6364fn validate_authority_reads(
6365    schema: &ClientSchema,
6366    declarations: &[crate::api::AuthorityReadDeclaration],
6367) -> Result<(), String> {
6368    let mut seen = BTreeSet::new();
6369    for read in declarations {
6370        let table = schema
6371            .table(&read.table)
6372            .ok_or("client.authority_read_forbidden: authority table is not declared")?;
6373        let plain = |name: &str| {
6374            table.columns.iter().enumerate().any(|(i, c)| {
6375                c.name == name
6376                    && name != "authority_version"
6377                    && !name.starts_with("_sync")
6378                    && !table.encrypted_columns.iter().any(|e| e.index == i)
6379                    && matches!(
6380                        c.ty,
6381                        ssp2::segment::ColumnType::String
6382                            | ssp2::segment::ColumnType::Integer
6383                            | ssp2::segment::ColumnType::Float
6384                            | ssp2::segment::ColumnType::Boolean
6385                            | ssp2::segment::ColumnType::Json
6386                    )
6387            })
6388        };
6389        if !seen.insert(&read.table)
6390            || read.columns.is_empty()
6391            || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
6392            || !read.columns.contains(&table.primary_key)
6393            || !read.columns.iter().all(|c| plain(c))
6394            || read.scopes.is_empty()
6395            || !read.scopes.iter().all(|(v, values)| {
6396                table
6397                    .scope_column(v)
6398                    .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
6399                    && !values.is_empty()
6400                    && values.iter().all(|v| !v.is_empty() && v != "*")
6401            })
6402        {
6403            return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
6404        }
6405    }
6406    Ok(())
6407}
6408
6409impl SyncClient {
6410    pub fn new_with_identity(
6411        client_id: Option<String>,
6412        schema_json: &Value,
6413        limits: ClientLimits,
6414    ) -> Result<Self, String> {
6415        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
6416        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
6417        Self::with_connection(resolved, schema_json, limits, conn)
6418    }
6419
6420    pub fn new(
6421        client_id: String,
6422        schema_json: &Value,
6423        limits: ClientLimits,
6424    ) -> Result<Self, String> {
6425        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
6426        Self::with_connection(client_id, schema_json, limits, conn)
6427    }
6428
6429    /// Build a client backed by an on-disk SQLite database at `path` — the
6430    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
6431    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
6432    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
6433    /// of the command router's dependency set (the router only holds a path).
6434    pub fn open_path(
6435        client_id: String,
6436        schema_json: &Value,
6437        limits: ClientLimits,
6438        path: &str,
6439    ) -> Result<Self, String> {
6440        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
6441        Self::with_connection(client_id, schema_json, limits, conn)
6442    }
6443
6444    pub fn open_path_with_identity(
6445        client_id: Option<String>,
6446        schema_json: &Value,
6447        limits: ClientLimits,
6448        path: &str,
6449    ) -> Result<Self, String> {
6450        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
6451        // File-backed native clients use an independent read connection for
6452        // latency-critical snapshots. WAL is SQLite's intended reader/writer
6453        // concurrency mode: a view read never holds a rollback-journal lock
6454        // that delays the mutable client's next commit, and a short busy
6455        // timeout absorbs the tiny checkpoint/schema-lock windows.
6456        conn.busy_timeout(std::time::Duration::from_millis(250))
6457            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
6458        conn.pragma_update(None, "journal_mode", "WAL")
6459            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
6460        let persisted = conn
6461            .query_row(
6462                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
6463                [],
6464                |row| row.get::<_, String>(0),
6465            )
6466            .ok();
6467        let resolved = persisted
6468            .clone()
6469            .or(client_id.clone())
6470            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
6471        if let (Some(existing), Some(requested)) = (persisted, client_id) {
6472            if existing != requested {
6473                return Err(format!(
6474                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
6475                ));
6476            }
6477        }
6478        Self::with_connection(resolved, schema_json, limits, conn)
6479    }
6480
6481    #[must_use]
6482    pub fn client_id(&self) -> &str {
6483        &self.client_id
6484    }
6485
6486    /// Build a client over a caller-supplied rusqlite connection — the seam a
6487    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
6488    /// with an on-disk database (`Connection::open(path)`) rather than the
6489    /// default `:memory:`. The connection MUST be fresh (no pre-existing
6490    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
6491    /// the same file across process restarts reuses the persisted rows.
6492    pub fn with_connection(
6493        client_id: String,
6494        schema_json: &Value,
6495        limits: ClientLimits,
6496        conn: Connection,
6497    ) -> Result<Self, String> {
6498        if limits.outcome_retention_max_entries == Some(0) {
6499            return Err(
6500                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
6501            );
6502        }
6503        let schema = parse_schema_json(schema_json)?;
6504        validate_authority_reads(&schema, &limits.authority_reads)?;
6505        // RFC 0005 D8: resolve the previous-version config before the opening
6506        // reset runs — the capture happens inside it. Bad bounds fail loud.
6507        let previous_version = match limits.previous_version_context {
6508            Some(config) => {
6509                config.validate()?;
6510                Some(config)
6511            }
6512            None => None,
6513        };
6514        let mut client = SyncClient {
6515            #[cfg(feature = "bench-internals")]
6516            benchmark_phases: Recorder::default(),
6517            conn,
6518            schema,
6519            client_id,
6520            limits,
6521            subs: Vec::new(),
6522            outbox: Vec::new(),
6523            failed_commits: Vec::new(),
6524            retain_failed_commits: false,
6525            conflicts: Vec::new(),
6526            rejections: Vec::new(),
6527            schema_floor: None,
6528            lease_state: None,
6529            stopped: false,
6530            upgrading: false,
6531            sync_needed: false,
6532            realtime_policy: RealtimePolicy::Optional,
6533            realtime_state: RealtimeState::Disconnected,
6534            realtime_reason_code: None,
6535            realtime_retry_delay_ms: None,
6536            presence: HashMap::new(),
6537            now_ms: None,
6538            encryption: crate::values::EncryptionConfig::default(),
6539            security_preflight: false,
6540            transport_enabled: true,
6541            previous_version,
6542            insert_sql: RefCell::new(HashMap::new()),
6543            overlay_dirty: OverlayDirty::clean(),
6544            storage_failure: RefCell::new(None),
6545            #[cfg(test)]
6546            overlay_rebuild_count: Cell::new(0),
6547            #[cfg(test)]
6548            acknowledged_replay_count: Cell::new(0),
6549            #[cfg(test)]
6550            outcome_prune_count: Cell::new(0),
6551            progress: ProgressObserver::default(),
6552            change_queue: VecDeque::new(),
6553            sync_intent_queue: VecDeque::new(),
6554            retry_delay_ms: 250,
6555            round_retry_delay_ms: None,
6556            active_round: None,
6557            last_round: None,
6558            last_change: None,
6559            query_failures: Vec::new(),
6560        };
6561        // The row write path leans on the prepared-statement cache (two
6562        // insert statements per synced table, plus the bookkeeping
6563        // statements); size it so a multi-table schema never thrashes.
6564        client
6565            .conn
6566            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
6567        // Protected bookkeeping must exist before inspecting the persisted
6568        // schema marker. New app indexes may reference columns that only
6569        // exist after a version-bump reset.
6570        client.create_bookkeeping_tables()?;
6571        match client.get_meta(CLIENT_ID_KEY) {
6572            Some(existing) if existing != client.client_id => {
6573                return Err(format!(
6574                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
6575                    client.client_id
6576                ));
6577            }
6578            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
6579            _ => {}
6580        }
6581        client.restore_persisted_state()?;
6582        client.failed_commits = client.load_failed_commits()?;
6583        let marker = client
6584            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6585            .and_then(|value| value.parse::<i32>().ok());
6586        match marker {
6587            None => {
6588                client.create_synced_tables()?;
6589                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
6590                // D1: the descriptor is written beside every marker write.
6591                set_local_schema_descriptor(&client.conn, &client.schema);
6592                client.save_subscription_scope_schema();
6593            }
6594            Some(version) if version == client.schema.version => {
6595                client.create_synced_tables()?;
6596                // RFC 0005 D1: the same-version open backfills the descriptor
6597                // for a database first opened by an unaware binary, so the
6598                // NEXT bump can capture without needing a schema bump first.
6599                set_local_schema_descriptor(&client.conn, &client.schema);
6600                client.save_subscription_scope_schema();
6601            }
6602            Some(_) => client.run_schema_reset()?,
6603        }
6604        // RFC 0005 D9: the reset sweep cannot see a container whose capture
6605        // committed in its OWN file while the replica's savepoint never
6606        // released — a crash at that seam leaves a container beside a matching
6607        // OLD marker, and the next open at that version runs no reset. Discard
6608        // it before anything can read it. No-op when no container exists.
6609        client.reconcile_previous_version_at_boot()?;
6610        client.clear_satisfied_persisted_schema_floor();
6611        client.prune_unknown_subscriptions(false)?;
6612        if marker == Some(client.schema.version)
6613            && (!client.outbox.is_empty()
6614                || !client.failed_commits.is_empty()
6615                || client.has_acknowledged_rows())
6616        {
6617            // Reconstruct the visible optimistic overlay from the durable base
6618            // plus outbox instead of trusting a process-interrupted mirror.
6619            client.overlay_dirty.set(true);
6620            client.rebuild_overlay();
6621        }
6622        // Every persisted active subscription needs one catch-up pull on open:
6623        // realtime only covers changes after connection, while an idempotent
6624        // setWindow correctly creates no fresh command effect. Pending outbox
6625        // work has the same restart requirement. The core owns this intent so
6626        // native hosts never poll or require an application-issued sync().
6627        client.enqueue_startup_sync_if_needed()?;
6628        Ok(client)
6629    }
6630
6631    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
6632    /// against this instead of system time (conformance virtual clock).
6633    pub fn set_now_ms(&mut self, now_ms: i64) {
6634        self.now_ms = Some(now_ms);
6635    }
6636
6637    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
6638    /// The command router parses these from the `create` command's
6639    /// `encryption` config (keys as `{$bytes: hex}`).
6640    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
6641        self.encryption = encryption;
6642    }
6643
6644    #[must_use]
6645    pub fn security_lifecycle(&self) -> &'static str {
6646        if self.security_preflight {
6647            "preflight"
6648        } else {
6649            "active"
6650        }
6651    }
6652
6653    #[must_use]
6654    pub fn security_preflight(&self) -> bool {
6655        self.security_preflight
6656    }
6657
6658    /// Host-owned, non-persisted network gate. Local operations remain available.
6659    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
6660        if self.transport_enabled != enabled {
6661            self.transport_enabled = enabled;
6662            self.sync_intent_queue.clear();
6663            self.sync_intent_queue
6664                .push_back(if enabled && !self.security_preflight {
6665                    SyncIntent::Interactive
6666                } else {
6667                    SyncIntent::None
6668                });
6669        }
6670        // A captured reply keeps its normal apply/revocation semantics.
6671        if !enabled && self.active_round.is_none() {
6672            self.disconnect_realtime(transport);
6673        }
6674    }
6675
6676    pub fn transport_enabled(&self) -> bool {
6677        self.transport_enabled
6678    }
6679
6680    /// Quarantine this replica: enter the fail-closed gate, release all
6681    /// core-owned key material, and record the gate in the database so
6682    /// reopening re-enters preflight until `activate_security` clears it.
6683    ///
6684    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
6685    /// which must NOT leave that durable mark.
6686    pub fn begin_security_preflight(&mut self) {
6687        self.seal_security_on_teardown();
6688        // Persist the quarantine so it survives handle teardown and restart:
6689        // reopening this replica re-enters preflight until activation clears it.
6690        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
6691    }
6692
6693    /// Release core-owned key material as a host tears the client down,
6694    /// WITHOUT recording a quarantine.
6695    ///
6696    /// Shutting an activated client down is not a quarantine event. Persisting
6697    /// the gate here marks every cleanly closed replica as pending, and the
6698    /// next plain `create` is then refused permanently: the reopen path
6699    /// restores the flag from the marker and the create guard rejects it. The
6700    /// in-memory flag still closes the gate for anything still holding this
6701    /// instance, which is all a teardown barrier needs.
6702    pub fn seal_security_on_teardown(&mut self) {
6703        self.cancel_sync_round();
6704        self.security_preflight = true;
6705        self.encryption = crate::values::EncryptionConfig::default();
6706        self.sync_intent_queue.clear();
6707    }
6708
6709    /// Install the post-authentication keyring and release the host loop.
6710    pub fn activate_security(
6711        &mut self,
6712        encryption: crate::values::EncryptionConfig,
6713    ) -> Result<(), String> {
6714        if !self.security_preflight {
6715            return Err(
6716                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
6717            );
6718        }
6719        self.enqueue_startup_sync_if_needed()?;
6720        self.encryption = encryption;
6721        self.security_preflight = false;
6722        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
6723        Ok(())
6724    }
6725
6726    fn clock_now_ms(&self) -> i64 {
6727        self.now_ms.unwrap_or_else(|| {
6728            std::time::SystemTime::now()
6729                .duration_since(std::time::UNIX_EPOCH)
6730                .map(|d| d.as_millis() as i64)
6731                .unwrap_or(0)
6732        })
6733    }
6734
6735    fn create_bookkeeping_tables(&self) -> Result<(), String> {
6736        // Durable client bookkeeping (outbox + subscription + meta).
6737        self.conn
6738            .execute_batch(
6739                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
6740                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
6741                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
6742                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
6743                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
6744                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
6745                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
6746                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
6747                   PRIMARY KEY(commit_id, idx));
6748                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
6749                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
6750                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
6751                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
6752                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
6753                   client_commit_id TEXT NOT NULL UNIQUE,
6754                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
6755                   recorded_at_ms INTEGER NOT NULL,
6756                   results_json TEXT NOT NULL,
6757                   operations_json TEXT,
6758                   resolution TEXT NOT NULL DEFAULT 'active'
6759                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
6760                   resolved_at_ms INTEGER,
6761                   replacement_client_commit_id TEXT);
6762                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
6763                   ON _syncular_commit_outcomes(resolution, seq);
6764                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
6765                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
6766                 CREATE TABLE IF NOT EXISTS _syncular_meta (
6767                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
6768                 CREATE TABLE IF NOT EXISTS _syncular_windows (
6769                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
6770                   PRIMARY KEY (base, unit));
6771                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
6772                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
6773                   effective_scopes TEXT NOT NULL);
6774                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
6775                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
6776                   PRIMARY KEY(commit_id, blob_id));
6777                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
6778                   ON _syncular_blob_commit_refs(blob_id);",
6779            )
6780            .map_err(|e| e.to_string())?;
6781        // Migrate an outcome journal created before failed aggregate
6782        // envelopes were retained. Historical rows intentionally stay NULL.
6783        let _ = self
6784            .conn
6785            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
6786        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
6787            self.set_meta(LOCAL_REVISION_KEY, "0");
6788        }
6789        // §5.9.7 blob cache. Mutable upload state stays off the body row so
6790        // completing a large upload does not rewrite its BLOB record.
6791        if self.schema_has_blobs() {
6792            let exists = self
6793                .conn
6794                .query_row(
6795                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
6796                    [],
6797                    |row| row.get::<_, bool>(0),
6798                )
6799                .map_err(|e| e.to_string())?;
6800            if exists {
6801                let mut statement = self
6802                    .conn
6803                    .prepare("PRAGMA table_info(_syncular_blobs)")
6804                    .map_err(|e| e.to_string())?;
6805                let mut columns = statement
6806                    .query_map([], |row| row.get::<_, String>(1))
6807                    .map_err(|e| e.to_string())?
6808                    .collect::<Result<Vec<_>, _>>()
6809                    .map_err(|e| e.to_string())?;
6810                columns.sort();
6811                let mut expected = vec![
6812                    "blob_id",
6813                    "byte_length",
6814                    "bytes",
6815                    "created_at_ms",
6816                    "media_type",
6817                ];
6818                expected.sort_unstable();
6819                if columns != expected {
6820                    return Err(
6821                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
6822                    );
6823                }
6824            } else {
6825                self.conn
6826                    .execute_batch(
6827                        "CREATE TABLE _syncular_blobs(
6828                           blob_id TEXT PRIMARY KEY,
6829                           bytes BLOB NOT NULL,
6830                           byte_length INTEGER NOT NULL,
6831                           media_type TEXT,
6832                           created_at_ms INTEGER NOT NULL);",
6833                    )
6834                    .map_err(|e| e.to_string())?;
6835            }
6836            self.conn
6837                .execute_batch(
6838                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
6839                       blob_id TEXT PRIMARY KEY,
6840                       media_type TEXT,
6841                       created_at_ms INTEGER NOT NULL);",
6842                )
6843                .map_err(|e| e.to_string())?;
6844        }
6845        Ok(())
6846    }
6847
6848    /// True iff any synced table declares a `blob_ref` column (§5.9).
6849    fn schema_has_blobs(&self) -> bool {
6850        self.schema
6851            .tables
6852            .iter()
6853            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
6854    }
6855
6856    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
6857
6858    fn get_meta(&self, key: &str) -> Option<String> {
6859        meta_get(&self.conn, key)
6860    }
6861
6862    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
6863        self.conn
6864            .query_row(
6865                "SELECT value FROM _syncular_meta WHERE key = ?1",
6866                rusqlite::params![key],
6867                |row| row.get::<_, String>(0),
6868            )
6869            .optional()
6870            .map_err(|_| {
6871                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
6872            })
6873    }
6874
6875    fn set_meta(&self, key: &str, value: &str) {
6876        meta_set(&self.conn, key, value);
6877    }
6878
6879    fn delete_meta(&self, key: &str) {
6880        meta_delete(&self.conn, key);
6881    }
6882
6883    fn restore_persisted_state(&mut self) -> Result<(), String> {
6884        self.subs = {
6885            let mut stmt = self
6886                .conn
6887                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
6888                .map_err(|error| error.to_string())?;
6889            let rows = stmt
6890                .query_map([], |row| {
6891                    Ok((
6892                        row.get::<_, String>(0)?,
6893                        row.get::<_, String>(1)?,
6894                        row.get::<_, String>(2)?,
6895                    ))
6896                })
6897                .map_err(|error| error.to_string())?;
6898            let mut subscriptions = Vec::new();
6899            for row in rows {
6900                let (id, table, raw) = row.map_err(|error| error.to_string())?;
6901                let state: Value = serde_json::from_str(&raw)
6902                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
6903                let requested = json_to_scope_map(
6904                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
6905                )?;
6906                let effective = state
6907                    .get("effectiveScopes")
6908                    .filter(|value| !value.is_null())
6909                    .map(json_to_scope_map)
6910                    .transpose()?;
6911                subscriptions.push(Subscription {
6912                    id,
6913                    table,
6914                    requested,
6915                    params: state
6916                        .get("params")
6917                        .and_then(Value::as_str)
6918                        .map(str::to_owned),
6919                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
6920                    bootstrap_state: state
6921                        .get("bootstrapState")
6922                        .and_then(Value::as_str)
6923                        .map(str::to_owned),
6924                    state: SubState::parse(
6925                        state
6926                            .get("status")
6927                            .and_then(Value::as_str)
6928                            .unwrap_or("active"),
6929                    ),
6930                    reason_code: state
6931                        .get("reasonCode")
6932                        .and_then(Value::as_str)
6933                        .map(str::to_owned),
6934                    effective,
6935                    synced_once: state
6936                        .get("syncedOnce")
6937                        .and_then(Value::as_bool)
6938                        .unwrap_or(false),
6939                });
6940            }
6941            subscriptions
6942        };
6943
6944        self.outbox = {
6945            let mut stmt = self
6946                .conn
6947                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6948                .map_err(|error| error.to_string())?;
6949            let rows = stmt
6950                .query_map([], |row| {
6951                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6952                })
6953                .map_err(|error| error.to_string())?;
6954            let mut commits = Vec::new();
6955            for row in rows {
6956                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6957                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6958                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
6959                })?;
6960                let mut ops = Vec::with_capacity(entries.len());
6961                for entry in entries {
6962                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6963                    ops.push(OutboxOp {
6964                        upsert: op == "upsert",
6965                        table: entry
6966                            .get("table")
6967                            .and_then(Value::as_str)
6968                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6969                            .to_owned(),
6970                        row_id: entry
6971                            .get("rowId")
6972                            .and_then(Value::as_str)
6973                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6974                            .to_owned(),
6975                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
6976                        // A legacy entry's `changedFields` key is ignored:
6977                        // the values map is the presence set (§6.1).
6978                        values: entry.get("values").and_then(Value::as_object).cloned(),
6979                    });
6980                }
6981                commits.push(OutboxCommit {
6982                    client_commit_id,
6983                    ops,
6984                });
6985            }
6986            commits
6987        };
6988
6989        self.prune_commit_outcomes()?;
6990        let active = self.commit_outcomes(CommitOutcomeQuery {
6991            active_only: true,
6992            ..CommitOutcomeQuery::default()
6993        })?;
6994        self.conflicts = active
6995            .iter()
6996            .flat_map(|outcome| outcome.results.iter())
6997            .filter_map(|result| match result {
6998                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6999                _ => None,
7000            })
7001            .collect();
7002        self.rejections = active
7003            .iter()
7004            .flat_map(|outcome| outcome.results.iter())
7005            .filter_map(|result| match result {
7006                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
7007                _ => None,
7008            })
7009            .collect();
7010
7011        self.lease_state = self
7012            .get_meta(LEASE_STATE_KEY)
7013            .map(|raw| serde_json::from_str(&raw))
7014            .transpose()
7015            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
7016        self.schema_floor = self
7017            .get_meta(SCHEMA_FLOOR_KEY)
7018            .map(|raw| serde_json::from_str(&raw))
7019            .transpose()
7020            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
7021        self.stopped = self.schema_floor.is_some();
7022        // A replica left in unactivated preflight reopens gated: the quarantine
7023        // decision persists with the data across handle teardown and restart.
7024        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
7025            self.security_preflight = true;
7026        }
7027        Ok(())
7028    }
7029
7030    fn save_subscription_scope_schema(&self) {
7031        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
7032            .schema
7033            .tables
7034            .iter()
7035            .map(|table| {
7036                (
7037                    table.name.clone(),
7038                    table
7039                        .scope_variables
7040                        .iter()
7041                        .map(|scope| {
7042                            (
7043                                scope.variable.clone(),
7044                                [scope.prefix.clone(), scope.column.clone()],
7045                            )
7046                        })
7047                        .collect(),
7048                )
7049            })
7050            .collect();
7051        self.set_meta(
7052            "subscriptionScopeSchema",
7053            &serde_json::to_string(&declarations).expect("scope schema serialization"),
7054        );
7055    }
7056
7057    /// Remove registrations with incompatible table/key/prefix/column meaning.
7058    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
7059        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
7060            self.get_meta("subscriptionScopeSchema")
7061                .map(|raw| serde_json::from_str(&raw))
7062                .transpose()
7063                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
7064        } else {
7065            None
7066        };
7067        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
7068            let Some(table) = self.schema.table(table_name) else {
7069                return false;
7070            };
7071            if requested
7072                .iter()
7073                .any(|(variable, _)| table.scope_column(variable).is_none())
7074            {
7075                return false;
7076            }
7077            if !bump {
7078                return true;
7079            }
7080            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
7081                return false;
7082            };
7083            let variables: BTreeSet<_> = if requested.is_empty() {
7084                old.keys()
7085                    .cloned()
7086                    .chain(
7087                        table
7088                            .scope_variables
7089                            .iter()
7090                            .map(|scope| scope.variable.clone()),
7091                    )
7092                    .collect()
7093            } else {
7094                requested
7095                    .iter()
7096                    .map(|(variable, _)| variable.clone())
7097                    .collect()
7098            };
7099            variables.iter().all(|variable| {
7100                let current = table
7101                    .scope_variables
7102                    .iter()
7103                    .find(|scope| &scope.variable == variable);
7104                match (old.get(variable), current) {
7105                    (Some(old), Some(current)) => {
7106                        old == &[current.prefix.clone(), current.column.clone()]
7107                    }
7108                    _ => false,
7109                }
7110            })
7111        };
7112        let mut stale_ids: BTreeSet<_> = self
7113            .subs
7114            .iter()
7115            .filter(|sub| !compatible(&sub.table, &sub.requested))
7116            .map(|sub| sub.id.clone())
7117            .collect();
7118        for (id, table, effective) in self.load_pending_evictions()? {
7119            if !compatible(&table, &effective) {
7120                stale_ids.insert(id);
7121            }
7122        }
7123        for id in &stale_ids {
7124            for sql in [
7125                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
7126                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7127                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7128            ] {
7129                self.conn
7130                    .execute(sql, rusqlite::params![id])
7131                    .map_err(|error| error.to_string())?;
7132            }
7133        }
7134        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
7135        Ok(())
7136    }
7137
7138    #[must_use]
7139    pub fn local_revision(&self) -> u64 {
7140        self.get_meta(LOCAL_REVISION_KEY)
7141            .and_then(|value| value.parse().ok())
7142            .unwrap_or(0)
7143    }
7144
7145    #[must_use]
7146    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
7147        SyncStatusSnapshot {
7148            current_schema_version: self.schema.version,
7149            outbox: self.outbox.len(),
7150            upgrading: self.upgrading,
7151            lease_state: self.lease_state.clone(),
7152            schema_floor: self.schema_floor.clone(),
7153            sync_needed: self.sync_needed,
7154            previous_version_context: self.previous_version_status(),
7155        }
7156    }
7157
7158    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
7159    /// rollback precondition), read without creating the file.
7160    fn previous_version_status(&self) -> PreviousVersionStatus {
7161        let Some(replica_path) = self.previous_version_replica_path() else {
7162            return PreviousVersionStatus {
7163                present: false,
7164                created_at_ms: None,
7165            };
7166        };
7167        match crate::previous_version::read_previous_version_record(&replica_path) {
7168            Ok(Some(record)) => PreviousVersionStatus {
7169                present: true,
7170                created_at_ms: Some(record.created_at_ms),
7171            },
7172            _ => PreviousVersionStatus {
7173                present: std::path::Path::new(
7174                    &crate::previous_version::previous_version_container_path(&replica_path),
7175                )
7176                .exists(),
7177                created_at_ms: None,
7178            },
7179        }
7180    }
7181
7182    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
7183    /// is always `previousVersion`; the container never makes
7184    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
7185    /// revocation, coverage completion and the TTL DISCARD the container and
7186    /// close the read with their own reason.
7187    pub fn previous_version_snapshot(
7188        &self,
7189        spec: &PreviousVersionReadSpec,
7190    ) -> Result<PreviousVersionSnapshot, String> {
7191        let now_ms = self.clock_now_ms();
7192        crate::previous_version::previous_version_snapshot(
7193            &self.conn,
7194            self.previous_version_replica_path().as_deref(),
7195            self.previous_version.as_ref(),
7196            PreviousVersionLifecycle {
7197                lease_inactive: self.previous_version_lease_inactive(now_ms),
7198                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
7199                coverage_complete: self.previous_version_coverage_complete(),
7200            },
7201            now_ms,
7202            self.schema.version,
7203            spec,
7204        )
7205    }
7206
7207    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
7208    #[must_use]
7209    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
7210        crate::previous_version::stored_previous_version_audit(&self.conn)
7211    }
7212
7213    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
7214    /// and both metadata records, and report whether anything was present.
7215    /// Idempotent: absence is a successful no-op.
7216    pub fn previous_version_discard(
7217        &self,
7218    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
7219        self.drop_previous_version()
7220    }
7221
7222    /// The module's discard has no failure path (it removes the file even when
7223    /// the container cannot be opened), so the only error a caller could ever
7224    /// see is a bug; report absence rather than claiming a discard happened.
7225    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
7226        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
7227            present: false,
7228            discarded: false,
7229        };
7230        let Some(replica_path) = self.previous_version_replica_path() else {
7231            return absent;
7232        };
7233        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
7234            .unwrap_or(absent)
7235    }
7236
7237    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
7238    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
7239        let Some(lease) = &self.lease_state else {
7240            return false;
7241        };
7242        if lease.error_code.is_some() {
7243            return true;
7244        }
7245        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
7246    }
7247
7248    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
7249    /// has `cursor >= 0` and no resume token. An empty active set is not
7250    /// completion (mirrors the TS predicate exactly).
7251    fn previous_version_coverage_complete(&self) -> bool {
7252        let active = self
7253            .subs
7254            .iter()
7255            .filter(|sub| sub.state == SubState::Active)
7256            .collect::<Vec<_>>();
7257        if active.is_empty() {
7258            return false;
7259        }
7260        active
7261            .iter()
7262            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
7263    }
7264
7265    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
7266    /// apply. A container that no longer has a reason to exist is discarded so
7267    /// the rows do not outlive the window they were kept for.
7268    fn previous_version_lifetime_check(&self) {
7269        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
7270            return;
7271        };
7272        let now_ms = self.clock_now_ms();
7273        if self.previous_version_lease_inactive(now_ms) {
7274            let _ = self.drop_previous_version();
7275            return;
7276        }
7277        let Some(replica_path) = self.previous_version_replica_path() else {
7278            return;
7279        };
7280        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
7281        else {
7282            return;
7283        };
7284        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
7285            let _ = self.drop_previous_version();
7286            return;
7287        }
7288        if now_ms - record.created_at_ms > config.max_age_ms {
7289            let _ = self.drop_previous_version();
7290            return;
7291        }
7292        if self.previous_version_coverage_complete() {
7293            let _ = self.drop_previous_version();
7294        }
7295    }
7296
7297    pub fn diagnostics_snapshot(
7298        &self,
7299        request: &ClientDiagnosticsRequest,
7300    ) -> Result<ClientDiagnosticsSnapshot, String> {
7301        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
7302            return Err(format!(
7303                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
7304            ));
7305        }
7306        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
7307        for sub in &self.subs {
7308            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
7309            let complete =
7310                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
7311            let state = match sub.state {
7312                SubState::Revoked => "revoked",
7313                SubState::Failed => "failed",
7314                SubState::Active if reset => "reset",
7315                SubState::Active if complete => "complete",
7316                SubState::Active => "bootstrapping",
7317            };
7318            subscriptions.insert(
7319                sub.id.clone(),
7320                DiagnosticSubscription {
7321                    id: sub.id.clone(),
7322                    table: sub.table.clone(),
7323                    state: state.to_owned(),
7324                    complete,
7325                    cursor: Some(sub.cursor),
7326                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
7327                },
7328            );
7329        }
7330        for expected in &request.expected_subscriptions {
7331            if expected.id.is_empty() || expected.table.is_empty() {
7332                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
7333            }
7334            if subscriptions
7335                .get(&expected.id)
7336                .is_some_and(|registered| registered.table != expected.table)
7337            {
7338                subscriptions.insert(
7339                    expected.id.clone(),
7340                    DiagnosticSubscription {
7341                        id: expected.id.clone(),
7342                        table: expected.table.clone(),
7343                        state: "failed".to_owned(),
7344                        complete: false,
7345                        cursor: None,
7346                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
7347                    },
7348                );
7349            } else {
7350                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
7351                    DiagnosticSubscription {
7352                        id: expected.id.clone(),
7353                        table: expected.table.clone(),
7354                        state: "unregistered".to_owned(),
7355                        complete: false,
7356                        cursor: None,
7357                        reason_code: None,
7358                    }
7359                });
7360            }
7361        }
7362        let mut ordered_subscriptions = Vec::new();
7363        let mut included = BTreeSet::new();
7364        for expected in &request.expected_subscriptions {
7365            if included.insert(expected.id.clone()) {
7366                if let Some(subscription) = subscriptions.get(&expected.id) {
7367                    ordered_subscriptions.push(subscription.clone());
7368                }
7369            }
7370        }
7371        for (id, subscription) in &subscriptions {
7372            if included.insert(id.clone()) {
7373                ordered_subscriptions.push(subscription.clone());
7374            }
7375        }
7376        let subscriptions_truncated =
7377            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
7378        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
7379        let captured_at_ms = self.clock_now_ms();
7380        let lease = if let Some(error_code) = self
7381            .lease_state
7382            .as_ref()
7383            .and_then(|state| state.error_code.clone())
7384        {
7385            ClientDiagnosticsLease {
7386                state: "stopped".to_owned(),
7387                expires_at_ms: self
7388                    .lease_state
7389                    .as_ref()
7390                    .and_then(|state| state.expires_at_ms),
7391                error_code: Some(Self::diagnostic_code(&error_code)),
7392            }
7393        } else if let Some(expires_at_ms) = self
7394            .lease_state
7395            .as_ref()
7396            .and_then(|state| state.expires_at_ms)
7397        {
7398            ClientDiagnosticsLease {
7399                state: if expires_at_ms <= captured_at_ms {
7400                    "expired".to_owned()
7401                } else {
7402                    "active".to_owned()
7403                },
7404                expires_at_ms: Some(expires_at_ms),
7405                error_code: None,
7406            }
7407        } else {
7408            ClientDiagnosticsLease {
7409                state: "none".to_owned(),
7410                expires_at_ms: None,
7411                error_code: None,
7412            }
7413        };
7414        let connectivity = match self.last_round.as_ref() {
7415            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
7416            Some(round) if round.status == "succeeded" => "online",
7417            Some(round)
7418                if round.status == "failed"
7419                    && round
7420                        .error_code
7421                        .as_deref()
7422                        .is_some_and(Self::retryable_transport_code) =>
7423            {
7424                "offline"
7425            }
7426            _ => "unknown",
7427        };
7428        Ok(ClientDiagnosticsSnapshot {
7429            version: CLIENT_DIAGNOSTICS_VERSION,
7430            captured_at_ms,
7431            host: ClientDiagnosticsHost {
7432                kind: "direct".to_owned(),
7433                role: "single".to_owned(),
7434                connectivity: connectivity.to_owned(),
7435                realtime: self.realtime_state,
7436                realtime_policy: self.realtime_policy,
7437                realtime_reason_code: self.realtime_reason_code.clone(),
7438                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
7439            },
7440            security_lifecycle: self.security_lifecycle().to_owned(),
7441            schema: ClientDiagnosticsSchema {
7442                current_version: self.schema.version,
7443                upgrading: self.upgrading,
7444                required_version: self
7445                    .schema_floor
7446                    .as_ref()
7447                    .and_then(|floor| floor.required_schema_version),
7448                latest_version: self
7449                    .schema_floor
7450                    .as_ref()
7451                    .and_then(|floor| floor.latest_schema_version),
7452            },
7453            replica: ClientDiagnosticsReplica {
7454                local_revision: self.local_revision().to_string(),
7455                sync_needed: self.sync_needed,
7456                pending_outbox: self.outbox.len(),
7457            },
7458            lease,
7459            subscriptions: ordered_subscriptions,
7460            subscriptions_truncated,
7461            last_round: self.last_round.clone(),
7462            last_change: self.last_change.clone(),
7463            storage: self.diagnostics_storage(),
7464            query_failures: self.query_failures.clone(),
7465        })
7466    }
7467
7468    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
7469        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
7470            let page_count: i64 = self
7471                .conn
7472                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
7473            let page_size: i64 = self
7474                .conn
7475                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
7476            // Reuse compiled aggregates; every invocation still reads current storage.
7477            let outbox_bytes: i64 = self
7478                .conn
7479                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
7480                .query_row([], |row| row.get(0))?;
7481            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
7482                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
7483            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
7484            let blob_bytes = if self.schema_has_blobs() {
7485                self.conn
7486                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
7487                    .query_row([], |row| row.get(0))?
7488            } else {
7489                0
7490            };
7491            let pressure = self
7492                .limits
7493                .blob_cache_max_bytes
7494                .is_some_and(|limit| blob_bytes > limit);
7495            Ok(ClientDiagnosticsStorage {
7496                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
7497                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
7498                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
7499                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
7500                retained_outcome_entries: Some(outcome_entries.max(0)),
7501                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
7502                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
7503            })
7504        };
7505        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
7506            status: "unreadable".to_owned(),
7507            database_bytes_approx: None,
7508            pending_outbox_bytes_approx: None,
7509            retained_outcome_bytes_approx: None,
7510            retained_outcome_entries: None,
7511            blob_cache_bytes_approx: None,
7512            pressure_reason_code: None,
7513        })
7514    }
7515
7516    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
7517        self.change_queue.drain(..).collect()
7518    }
7519
7520    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
7521        let intents = self.sync_intent_queue.drain(..).collect();
7522        if self.transport_enabled {
7523            intents
7524        } else {
7525            vec![SyncIntent::None]
7526        }
7527    }
7528
7529    fn schedule_background_retry(&mut self) -> u64 {
7530        let delay_ms = self.retry_delay_ms;
7531        self.sync_intent_queue
7532            .push_back(SyncIntent::Background { delay_ms });
7533        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
7534        self.round_retry_delay_ms = Some(delay_ms);
7535        delay_ms
7536    }
7537
7538    fn reset_background_retry(&mut self) {
7539        self.retry_delay_ms = 250;
7540    }
7541
7542    fn retryable_transport_code(code: &str) -> bool {
7543        code == "transport.failed"
7544            || code == "transport.unavailable"
7545            || code == "sync.transport_failed"
7546    }
7547
7548    /// A failed round worth a background retry: a transport failure, or a
7549    /// §10.2 code the catalog marks retryable (clients may hardcode the
7550    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
7551    /// on the server's `retryable` flag.
7552    fn retryable_failure_code(code: &str) -> bool {
7553        Self::retryable_transport_code(code)
7554            || matches!(
7555                code,
7556                "sync.auth_required"
7557                    | "sync.auth_lease_required"
7558                    | "sync.auth_lease_revoked"
7559                    | "sync.internal_error"
7560                    | "sync.idempotency_cache_miss"
7561                    | "sync.schema_not_ready"
7562                    | "sync.segment_expired"
7563                    | "sync.rate_limited"
7564                    | "sync.websocket_connection_limit"
7565            )
7566    }
7567
7568    fn diagnostic_code(code: &str) -> String {
7569        let valid = !code.is_empty()
7570            && code.len() <= 96
7571            && code.contains('.')
7572            && code
7573                .bytes()
7574                .next()
7575                .is_some_and(|byte| byte.is_ascii_lowercase())
7576            && code.bytes().all(|byte| {
7577                byte.is_ascii_lowercase()
7578                    || byte.is_ascii_digit()
7579                    || matches!(byte, b'.' | b'_' | b'-')
7580            });
7581        if valid {
7582            code.to_owned()
7583        } else {
7584            "client.unknown_failure".to_owned()
7585        }
7586    }
7587
7588    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
7589        if self.sync_needed != value {
7590            if self.begin_observation("syncular_status").is_ok() {
7591                self.sync_needed = value;
7592                let batch = ChangeAccumulator {
7593                    status: true,
7594                    ..ChangeAccumulator::default()
7595                };
7596                if self.finish_observation("syncular_status", batch).is_err() {
7597                    self.rollback_observation("syncular_status");
7598                }
7599            } else {
7600                self.sync_needed = value;
7601            }
7602        }
7603        if value && interactive {
7604            self.sync_intent_queue.push_back(SyncIntent::Interactive);
7605        }
7606    }
7607
7608    fn sqlite_failure(
7609        storage_failure: &RefCell<Option<QueryReadFailure>>,
7610        error: rusqlite::Error,
7611    ) -> String {
7612        let message = error.to_string();
7613        let failure = QueryReadFailure::from(error);
7614        let code = failure.code;
7615        if code.is_some() && storage_failure.borrow().is_none() {
7616            *storage_failure.borrow_mut() = Some(failure);
7617        }
7618        match code {
7619            Some(code) => format!("{code}: {message}"),
7620            None => message,
7621        }
7622    }
7623
7624    fn begin_observation(&self, name: &str) -> Result<(), String> {
7625        self.conn
7626            .execute_batch(&format!("SAVEPOINT {name}"))
7627            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7628    }
7629
7630    fn rollback_observation(&self, name: &str) {
7631        if let Err(error) = self
7632            .conn
7633            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
7634        {
7635            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
7636                if failure.rollback_failure.is_none() {
7637                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
7638                }
7639            }
7640        }
7641    }
7642
7643    fn finish_observation(
7644        &mut self,
7645        name: &str,
7646        mut batch: ChangeAccumulator,
7647    ) -> Result<(), String> {
7648        #[cfg(feature = "bench-internals")]
7649        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
7650        if self.failed_commits.iter().any(|(commit, _)| {
7651            commit
7652                .ops
7653                .iter()
7654                .any(|operation| batch.tables.contains_key(&operation.table))
7655        }) {
7656            batch.outcomes = true;
7657        }
7658        if !batch.touched() {
7659            self.conn
7660                .execute_batch(&format!("RELEASE {name}"))
7661                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7662            return Ok(());
7663        }
7664        let revision = self
7665            .local_revision()
7666            .checked_add(1)
7667            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
7668        self.conn
7669            .execute(
7670                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
7671                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
7672            )
7673            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7674        let status = batch.status.then(|| self.status_snapshot());
7675        let event = ClientChangeBatch {
7676            revision: revision.to_string(),
7677            tables: batch
7678                .tables
7679                .into_iter()
7680                .map(|(table, scope_keys)| TableChange {
7681                    table,
7682                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
7683                })
7684                .collect(),
7685            windows: batch
7686                .windows
7687                .into_iter()
7688                .map(|((base_key, table), units)| WindowChange {
7689                    base_key,
7690                    table,
7691                    units: units.into_iter().collect(),
7692                })
7693                .collect(),
7694            status,
7695            conflicts_changed: batch.conflicts,
7696            rejections_changed: batch.rejections,
7697            outcomes_changed: batch.outcomes,
7698        };
7699        self.conn
7700            .execute_batch(&format!("RELEASE {name}"))
7701            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7702        let mut diagnostic_tables = event
7703            .tables
7704            .iter()
7705            .map(|entry| entry.table.clone())
7706            .collect::<BTreeSet<_>>()
7707            .into_iter()
7708            .collect::<Vec<_>>();
7709        let mut diagnostic_windows = event
7710            .windows
7711            .iter()
7712            .map(|entry| entry.table.clone())
7713            .collect::<BTreeSet<_>>()
7714            .into_iter()
7715            .collect::<Vec<_>>();
7716        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
7717            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
7718        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
7719        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
7720        self.last_change = Some(DiagnosticLastChange {
7721            revision: event.revision.clone(),
7722            recorded_at_ms: self.clock_now_ms(),
7723            tables: diagnostic_tables,
7724            windows: diagnostic_windows,
7725            domains_truncated,
7726            status_changed: event.status.is_some(),
7727            conflicts_changed: event.conflicts_changed,
7728            rejections_changed: event.rejections_changed,
7729            outcomes_changed: event.outcomes_changed,
7730        });
7731        self.change_queue.push_back(event);
7732        Ok(())
7733    }
7734
7735    fn record_scope_map(
7736        &self,
7737        batch: &mut ChangeAccumulator,
7738        table_name: &str,
7739        scopes: &[(String, Vec<String>)],
7740    ) {
7741        let Some(table) = self.schema.table(table_name) else {
7742            return;
7743        };
7744        for (variable, values) in scopes {
7745            let Some(scope) = table
7746                .scope_variables
7747                .iter()
7748                .find(|scope| &scope.variable == variable)
7749            else {
7750                continue;
7751            };
7752            for value in values {
7753                batch.scope(table_name, format!("{}:{value}", scope.prefix));
7754            }
7755        }
7756    }
7757
7758    /// Record a row's current scope keys from the base or visible table.
7759    fn record_row_scopes(
7760        &self,
7761        batch: &mut ChangeAccumulator,
7762        table_name: &str,
7763        row_id: &str,
7764        base: bool,
7765    ) -> bool {
7766        let Some(table) = self.schema.table(table_name) else {
7767            return false;
7768        };
7769        if table.scope_variables.is_empty() {
7770            return false;
7771        }
7772        let columns = table
7773            .scope_variables
7774            .iter()
7775            .map(|scope| quote_ident(&scope.column))
7776            .collect::<Vec<_>>()
7777            .join(", ");
7778        let full_table = if base {
7779            base_table(table_name)
7780        } else {
7781            visible_table(table_name)
7782        };
7783        let sql = format!(
7784            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
7785            row_id_predicate(table)
7786        );
7787        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
7788            return false;
7789        };
7790        let values = stmt.query_row(rusqlite::params![row_id], |row| {
7791            let mut values = Vec::with_capacity(table.scope_variables.len());
7792            for index in 0..table.scope_variables.len() {
7793                values.push(row.get::<_, Option<String>>(index)?);
7794            }
7795            Ok(values)
7796        });
7797        let Ok(values) = values else {
7798            return false;
7799        };
7800        let mut recorded = false;
7801        for (scope, value) in table.scope_variables.iter().zip(values) {
7802            if let Some(value) = value {
7803                batch.scope(table_name, format!("{}:{value}", scope.prefix));
7804                recorded = true;
7805            }
7806        }
7807        recorded
7808    }
7809
7810    fn record_commit_changes(
7811        &self,
7812        batch: &mut ChangeAccumulator,
7813        tables: &[String],
7814        changes: &[ssp2::model::Change],
7815    ) {
7816        #[cfg(feature = "bench-internals")]
7817        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
7818        for change in changes {
7819            let Some(table_name) = tables.get(change.table_index as usize) else {
7820                continue;
7821            };
7822            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
7823            if let Some(table) = self.schema.table(table_name) {
7824                for (variable, value) in &change.scopes {
7825                    if let Some(scope) = table
7826                        .scope_variables
7827                        .iter()
7828                        .find(|scope| &scope.variable == variable)
7829                    {
7830                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
7831                        precise = true;
7832                    }
7833                }
7834            }
7835            if !precise {
7836                batch.table(table_name);
7837            }
7838        }
7839    }
7840
7841    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
7842        if effective.is_empty() {
7843            return false;
7844        }
7845        let Some(table) = self.schema.table(table_name) else {
7846            return false;
7847        };
7848        let mut clauses = Vec::new();
7849        let mut params = Vec::new();
7850        for (variable, values) in effective {
7851            let Some(column) = table.scope_column(variable) else {
7852                return false;
7853            };
7854            if values.is_empty() {
7855                return false;
7856            }
7857            let placeholders = values
7858                .iter()
7859                .map(|value| {
7860                    params.push(SqlValue::Text(value.clone()));
7861                    "?"
7862                })
7863                .collect::<Vec<_>>()
7864                .join(", ");
7865            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
7866        }
7867        let sql = format!(
7868            "SELECT 1 FROM {} WHERE {} LIMIT 1",
7869            base_table(table_name),
7870            clauses.join(" AND ")
7871        );
7872        self.conn
7873            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
7874            .is_ok()
7875    }
7876
7877    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
7878    pub fn upgrading(&self) -> bool {
7879        self.upgrading
7880    }
7881
7882    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
7883    /// keeping this client's local database (identity, outbox, tables). The
7884    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
7885    /// version changed. Mirrors the TS client's boot-time detection —
7886    /// the Rust core has no persistent restart, so recreation IS the boot.
7887    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
7888        let new_schema = parse_schema_json(schema_json)?;
7889        validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
7890        let marker: Option<i32> = self
7891            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7892            .and_then(|v| v.parse().ok());
7893        self.schema = new_schema;
7894        self.create_bookkeeping_tables()?;
7895        if marker != Some(self.schema.version) {
7896            self.run_schema_reset()?;
7897        }
7898        self.prune_unknown_subscriptions(false)?;
7899        self.save_subscription_scope_schema();
7900        // The conformance recreate is the in-memory equivalent of reopening a
7901        // durable client. Apply the same startup catch-up contract even when
7902        // the schema itself did not change.
7903        self.enqueue_startup_sync_if_needed()?;
7904        Ok(())
7905    }
7906
7907    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
7908        let startup_work = !self.stopped
7909            && (!self.outbox.is_empty()
7910                || self.has_acknowledged_rows()
7911                || !self.load_pending_evictions()?.is_empty()
7912                || self.subs.iter().any(|sub| sub.state == SubState::Active));
7913        if startup_work {
7914            self.sync_needed = true;
7915            self.sync_intent_queue.push_back(SyncIntent::Interactive);
7916        }
7917        Ok(())
7918    }
7919
7920    /// A native client persists schema-floor stops across process restarts.
7921    /// Once the running app already satisfies that floor, the persisted stop
7922    /// is only stale evidence from an older server round (for example, the
7923    /// server was restarted after catching up to an app that was ahead).
7924    /// Clear it and let the normal startup pull re-negotiate. If the server is
7925    /// still incompatible it will return the floor again in that first round.
7926    fn clear_satisfied_persisted_schema_floor(&mut self) {
7927        let satisfied = self
7928            .schema_floor
7929            .as_ref()
7930            .and_then(|floor| floor.required_schema_version)
7931            .is_some_and(|required| self.schema.version >= required);
7932        if !satisfied {
7933            return;
7934        }
7935        self.schema_floor = None;
7936        self.stopped = false;
7937        self.delete_meta(SCHEMA_FLOOR_KEY);
7938    }
7939
7940    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
7941    /// path already swept it; this covers a same-version or fresh-install open,
7942    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
7943    /// an aware binary can apply.
7944    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
7945        let Some(replica_path) = self.previous_version_replica_path() else {
7946            return Ok(());
7947        };
7948        let max_age_ms = self
7949            .previous_version
7950            .filter(|config| config.enabled)
7951            .map(|config| config.max_age_ms);
7952        reconcile_previous_version_at_boot(
7953            &self.conn,
7954            &replica_path,
7955            self.schema.version,
7956            max_age_ms,
7957            self.clock_now_ms(),
7958        )
7959    }
7960
7961    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
7962    /// no file — an in-memory replica returns an empty path, and a `file:` URI
7963    /// has no plain sibling path — so the feature resolves as off there.
7964    fn previous_version_replica_path(&self) -> Option<String> {
7965        let path = self.conn.path()?;
7966        if path.is_empty() || path.starts_with("file:") {
7967            return None;
7968        }
7969        Some(path.to_owned())
7970    }
7971
7972    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
7973    /// and leaseState. Drops/recreates every synced table from the new
7974    /// schema, resets subscription sync-state (keeping registrations), clears
7975    /// the schema-floor stop state, rewrites the marker, drops outbox commits
7976    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
7977    fn run_schema_reset(&mut self) -> Result<(), String> {
7978        self.begin_observation("syncular_schema_reset")?;
7979        let prior_subs = self.subs.clone();
7980        let mut batch = ChangeAccumulator::default();
7981        let result = self.run_schema_reset_observed(&mut batch, true, true);
7982        if let Err(error) = result {
7983            self.rollback_observation("syncular_schema_reset");
7984            self.subs = prior_subs;
7985            return Err(error);
7986        }
7987        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
7988            self.rollback_observation("syncular_schema_reset");
7989            self.subs = prior_subs;
7990            return Err(error);
7991        }
7992        Ok(())
7993    }
7994
7995    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
7996        if self.get_meta(LOG_EPOCH_KEY).is_none() {
7997            self.begin_observation("syncular_epoch_acquisition")?;
7998            let previous_sync_needed = self.sync_needed;
7999            if let Err(error) = self.conn.execute(
8000                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
8001                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
8002            ) {
8003                self.rollback_observation("syncular_epoch_acquisition");
8004                return Err(Self::sqlite_failure(&self.storage_failure, error));
8005            }
8006            self.sync_needed = true;
8007            let batch = ChangeAccumulator {
8008                status: true,
8009                ..ChangeAccumulator::default()
8010            };
8011            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
8012                self.rollback_observation("syncular_epoch_acquisition");
8013                self.sync_needed = previous_sync_needed;
8014                return Err(error);
8015            }
8016            self.sync_intent_queue.push_back(SyncIntent::Interactive);
8017            return Ok(Vec::new());
8018        }
8019        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
8020        self.begin_observation("syncular_log_epoch_reset")?;
8021        let prior_subs = self.subs.clone();
8022        let mut batch = ChangeAccumulator::default();
8023        let result = self.run_schema_reset_observed(&mut batch, false, false);
8024        if let Err(error) = result {
8025            self.rollback_observation("syncular_log_epoch_reset");
8026            self.subs = prior_subs;
8027            return Err(error);
8028        }
8029        self.set_meta(LOG_EPOCH_KEY, log_epoch);
8030        self.sync_needed = true;
8031        batch.status = true;
8032        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
8033            self.rollback_observation("syncular_log_epoch_reset");
8034            self.subs = prior_subs;
8035            return Err(error);
8036        }
8037        self.sync_intent_queue.push_back(SyncIntent::Interactive);
8038        Ok(resets)
8039    }
8040
8041    fn run_schema_reset_observed(
8042        &mut self,
8043        batch: &mut ChangeAccumulator,
8044        drop_incompatible: bool,
8045        capture: bool,
8046    ) -> Result<(), String> {
8047        self.conn
8048            .execute_batch(
8049                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
8050            )
8051            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8052        self.cancel_sync_round();
8053        self.upgrading = true;
8054        batch.status = true;
8055        // RFC 0005 D6: classify the pending outbox against the NEW compiled
8056        // schema BEFORE the wipe, and record the advisory audit. It drops
8057        // nothing — the §7.4.4 send-time drop stays the only path that removes
8058        // a commit. The classification is shared with that drop so the two can
8059        // never disagree about what is incompatible.
8060        if self.previous_version.is_some_and(|config| config.enabled) {
8061            let previous_version = self
8062                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
8063                .and_then(|value| value.parse::<i32>().ok())
8064                .unwrap_or(self.schema.version);
8065            let pending = self
8066                .outbox
8067                .iter()
8068                .map(|commit| PendingCommitAudit {
8069                    commit_id: &commit.client_commit_id,
8070                    operations: commit_audit_operations(commit),
8071                })
8072                .collect::<Vec<_>>();
8073            crate::previous_version::write_previous_version_audit(
8074                &self.conn,
8075                &crate::previous_version::build_previous_version_audit(
8076                    &self.schema,
8077                    &pending,
8078                    previous_version,
8079                    self.schema.version,
8080                    self.clock_now_ms(),
8081                ),
8082            );
8083        }
8084        // RFC 0005 D5: the container lives in its own FILE, so the reset can
8085        // never reach it. Sweep first (unconditional — also on the log-epoch
8086        // reset, which captures nothing), then capture BEFORE the wipe drops
8087        // any row. A refused capture records its reason and leaves no file.
8088        if let Some(replica_path) = self.previous_version_replica_path() {
8089            sweep_previous_version_container(&self.conn, &replica_path)?;
8090            if capture {
8091                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
8092                    let previous_version = self
8093                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
8094                        .and_then(|value| value.parse::<i32>().ok())
8095                        .unwrap_or(self.schema.version);
8096                    capture_previous_version_from_replica(
8097                        &self.conn,
8098                        &replica_path,
8099                        previous_version,
8100                        self.schema.version,
8101                        &config,
8102                        self.clock_now_ms(),
8103                    )?;
8104                }
8105            }
8106        }
8107        for table in &self.schema.tables {
8108            batch.table(&table.name);
8109        }
8110        for (base_key, unit, table) in self.load_registered_window_units() {
8111            batch.window(&base_key, &table, &unit);
8112        }
8113        if capture {
8114            self.prune_unknown_subscriptions(true)?;
8115        }
8116        // The per-table insert SQL is derived from the OLD column lists.
8117        self.insert_sql.borrow_mut().clear();
8118        self.overlay_dirty.set(true);
8119        // Drop every synced table (base + visible) that currently exists —
8120        // discovered from sqlite_master so a bump that adds/removes tables is
8121        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
8122        // and the blob cache) are preserved; base tables are `_syncular_base_*`
8123        // so they are matched explicitly, not by the bookkeeping filter.
8124        // Drop virtual tables first so SQLite removes every FTS shadow table
8125        // atomically instead of the generic discovery tearing it apart.
8126        let virtual_tables: Vec<String> = {
8127            let mut stmt = self
8128                .conn
8129                .prepare(
8130                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
8131                )
8132                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8133            let rows = stmt
8134                .query_map([], |row| row.get::<_, String>(0))
8135                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8136            rows.filter_map(Result::ok)
8137                .filter(|name| is_synced_table_name(name))
8138                .collect()
8139        };
8140        for name in virtual_tables {
8141            self.conn
8142                .execute_batch(&format!(
8143                    "DROP TABLE IF EXISTS {}",
8144                    quote_ident(&format!("_syncular_fts_{name}"))
8145                ))
8146                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8147            self.conn
8148                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
8149                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8150        }
8151        let existing: Vec<String> = {
8152            let mut stmt = self
8153                .conn
8154                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
8155                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8156            let rows = stmt
8157                .query_map([], |row| row.get::<_, String>(0))
8158                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8159            rows.filter_map(Result::ok)
8160                .filter(|name| is_synced_table_name(name))
8161                .collect()
8162        };
8163        for name in &existing {
8164            if let Some(table) = name.strip_prefix("_syncular_base_") {
8165                batch.table(table);
8166            } else if !name.starts_with("_syncular_") {
8167                batch.table(name);
8168            }
8169        }
8170        for name in existing {
8171            self.conn
8172                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
8173                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8174        }
8175        // Recreate the synced tables from the NEW schema.
8176        self.create_synced_tables()?;
8177        // Reset every subscription's sync-state, keeping the registration.
8178        for sub in &mut self.subs {
8179            sub.cursor = -1;
8180            sub.bootstrap_state = None;
8181            sub.effective = None;
8182            sub.state = SubState::Active;
8183            sub.reason_code = None;
8184            sub.synced_once = false;
8185        }
8186        let subs = self.subs.clone();
8187        for sub in &subs {
8188            self.persist_sub(sub)?;
8189        }
8190        // The stop state is over: this client now ships a servable schema.
8191        self.stopped = false;
8192        self.schema_floor = None;
8193        self.delete_meta(SCHEMA_FLOOR_KEY);
8194        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
8195        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
8196        // D1: the descriptor follows every marker write. A crash in between
8197        // self-heals on the next same-version open, which backfills it.
8198        set_local_schema_descriptor(&self.conn, &self.schema);
8199        self.save_subscription_scope_schema();
8200        // §7.4.4: drop outbox commits that cannot re-encode under the new
8201        // schema (a referenced column/table the bump removed), surfacing each
8202        // as a `sync.outbox_incompatible` rejection.
8203        if drop_incompatible && self.drop_incompatible_outbox()? {
8204            batch.rejections = true;
8205            batch.status = true;
8206            batch.outcomes = true;
8207        }
8208        // Re-apply the surviving outbox optimistically over the empty tables.
8209        self.rebuild_overlay();
8210        Ok(())
8211    }
8212
8213    /// §7.4.4: a persisted upsert whose values reference a column the current
8214    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
8215    /// and raise a client-local `sync.outbox_incompatible` rejection.
8216    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
8217        let incompatible = self
8218            .outbox
8219            .iter()
8220            .filter(|commit| {
8221                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
8222            })
8223            .cloned()
8224            .collect::<Vec<_>>();
8225        if incompatible.is_empty() {
8226            return Ok(false);
8227        }
8228        let mut rejections = Vec::new();
8229        for commit in &incompatible {
8230            let results = commit
8231                .ops
8232                .iter()
8233                .enumerate()
8234                .map(|(op_index, operation)| {
8235                    let rejection = RejectionRecord {
8236                        client_commit_id: commit.client_commit_id.clone(),
8237                        op_index: op_index as i32,
8238                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
8239                        message: "the persisted commit cannot encode under the current schema"
8240                            .to_owned(),
8241                        retryable: false,
8242                        details: None,
8243                        operation: Some(CommitOperation::from(operation)),
8244                    };
8245                    rejections.push(rejection.clone());
8246                    CommitOperationOutcome::Error { rejection }
8247                })
8248                .collect::<Vec<_>>();
8249            self.persist_commit_outcome(
8250                &commit.client_commit_id,
8251                CommitOutcomeStatus::Rejected,
8252                &results,
8253                Some(&commit.ops),
8254            )?;
8255            self.delete_outbox_persisted(&commit.client_commit_id)?;
8256        }
8257        self.prune_commit_outcomes()?;
8258        let incompatible_ids = incompatible
8259            .iter()
8260            .map(|commit| commit.client_commit_id.as_str())
8261            .collect::<BTreeSet<_>>();
8262        self.outbox
8263            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
8264        self.rejections.extend(rejections);
8265        Ok(true)
8266    }
8267
8268    /// §7.4.3: (re)create the base + visible table pair for every synced
8269    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
8270    fn create_synced_tables(&self) -> Result<(), String> {
8271        for table in &self.schema.tables {
8272            // The base half + the visible half form the synced-table pair. An
8273            // index name is global in SQLite, so the base half's indexes are
8274            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
8275            for (full, index_prefix) in [
8276                (base_table(&table.name), "_syncular_base_"),
8277                (visible_table(&table.name), ""),
8278            ] {
8279                let mut cols: Vec<String> =
8280                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
8281                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
8282                let sql = format!(
8283                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
8284                    cols.join(", "),
8285                    quote_ident(&table.primary_key)
8286                );
8287                self.conn
8288                    .execute(&sql, [])
8289                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8290                // Local secondary indexes (CREATE INDEX subset). Created on
8291                // both halves so mirror reads hit an index on either. Runs on
8292                // both the initial create and the §7.4.3 reset recreate path.
8293                for index in &table.indexes {
8294                    let unique = if index.unique { "UNIQUE " } else { "" };
8295                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
8296                    let cols_sql = index
8297                        .columns
8298                        .iter()
8299                        .map(|c| quote_ident(c))
8300                        .collect::<Vec<_>>()
8301                        .join(", ");
8302                    let index_sql = format!(
8303                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
8304                    );
8305                    self.conn
8306                        .execute(&index_sql, [])
8307                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8308                }
8309            }
8310        }
8311        // FTS exists only on the visible half. It is a local search
8312        // projection, never a synced/base table or a wire-schema member.
8313        for table in &self.schema.tables {
8314            for index in &table.fts_indexes {
8315                self.create_fts_projection(table, index)?;
8316            }
8317        }
8318        Ok(())
8319    }
8320
8321    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
8322        let count: i64 = self
8323            .conn
8324            .query_row(
8325                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
8326                rusqlite::params![index.name],
8327                |row| row.get(0),
8328            )
8329            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8330        Ok(count > 0)
8331    }
8332
8333    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
8334        for suffix in ["ai", "ad", "au"] {
8335            self.conn
8336                .execute(
8337                    &format!(
8338                        "DROP TRIGGER IF EXISTS {}",
8339                        quote_ident(&format!("{}_{suffix}", index.name))
8340                    ),
8341                    [],
8342                )
8343                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8344        }
8345        Ok(())
8346    }
8347
8348    fn create_fts_triggers(
8349        &self,
8350        table: &TableSchema,
8351        index: &FtsIndexSchema,
8352    ) -> Result<(), String> {
8353        let fts = quote_ident(&index.name);
8354        let source = visible_table(&table.name);
8355        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
8356        let pk = quote_ident(&table.primary_key);
8357        let projection_columns = std::iter::once(source_id.clone())
8358            .chain(index.columns.iter().map(|column| quote_ident(column)))
8359            .collect::<Vec<_>>()
8360            .join(", ");
8361        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
8362            .chain(
8363                index
8364                    .columns
8365                    .iter()
8366                    .map(|column| format!("new.{}", quote_ident(column))),
8367            )
8368            .collect::<Vec<_>>()
8369            .join(", ");
8370        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8371        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)");
8372        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)");
8373        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})");
8374        let bi = quote_ident(&format!("{}_bi", index.name));
8375        let ai = quote_ident(&format!("{}_ai", index.name));
8376        let ad = quote_ident(&format!("{}_ad", index.name));
8377        let au = quote_ident(&format!("{}_au", index.name));
8378        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
8379        let sql = format!(
8380            "DROP TRIGGER IF EXISTS {bi};
8381             DROP TRIGGER IF EXISTS {ai};
8382             DROP TRIGGER IF EXISTS {ad};
8383             DROP TRIGGER IF EXISTS {au};
8384             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
8385             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
8386             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
8387             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
8388        );
8389        self.conn
8390            .execute_batch(&sql)
8391            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8392    }
8393
8394    fn rebuild_fts_projection(
8395        &self,
8396        table: &TableSchema,
8397        index: &FtsIndexSchema,
8398    ) -> Result<(), String> {
8399        let fts = quote_ident(&index.name);
8400        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
8401        let pk = quote_ident(&table.primary_key);
8402        let indexed_columns = index
8403            .columns
8404            .iter()
8405            .map(|column| quote_ident(column))
8406            .collect::<Vec<_>>();
8407        let projection_columns = std::iter::once(source_id.clone())
8408            .chain(indexed_columns.iter().cloned())
8409            .collect::<Vec<_>>()
8410            .join(", ");
8411        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8412        let sql = format!(
8413            "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};",
8414            columns = indexed_columns.join(", "),
8415            source = visible_table(&table.name),
8416        );
8417        self.conn
8418            .execute_batch(&sql)
8419            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8420    }
8421
8422    fn create_fts_projection(
8423        &self,
8424        table: &TableSchema,
8425        index: &FtsIndexSchema,
8426    ) -> Result<(), String> {
8427        self.begin_observation("syncular_fts_schema")?;
8428        let result = (|| {
8429            let existed = self.fts_projection_exists(index)?;
8430            let tokenizer = index.tokenize.replace('\'', "''");
8431            let columns = index
8432                .columns
8433                .iter()
8434                .map(|column| quote_ident(column))
8435                .collect::<Vec<_>>()
8436                .join(", ");
8437            let sql = format!(
8438            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
8439            fts = quote_ident(&index.name),
8440            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
8441        );
8442            self.conn.execute(&sql, []).map_err(|error| {
8443                format!(
8444                    "cannot create local FTS5 projection {:?}: {error}",
8445                    index.name
8446                )
8447            })?;
8448            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8449            let mapped: bool = self
8450                .conn
8451                .query_row(
8452                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
8453                    [format!("_syncular_fts_{}", index.name)],
8454                    |row| row.get(0),
8455                )
8456                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8457            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))?;
8458            if !mapped && existed {
8459                self.conn
8460                    .execute_batch(&format!(
8461                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
8462                        quote_ident(FTS_SOURCE_ID_COLUMN),
8463                        quote_ident(&index.name)
8464                    ))
8465                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8466            }
8467            self.create_fts_triggers(table, index)?;
8468            if !existed {
8469                self.rebuild_fts_projection(table, index)?;
8470            }
8471            Ok(())
8472        })();
8473        if let Err(error) = result {
8474            self.rollback_observation("syncular_fts_schema");
8475            return Err(error);
8476        }
8477        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
8478            self.rollback_observation("syncular_fts_schema");
8479            return Err(error.to_string());
8480        }
8481        Ok(())
8482    }
8483
8484    // -- persistence write-through --------------------------------------------
8485
8486    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
8487        let state = serde_json::json!({
8488            "requested": scope_map_to_json(&sub.requested),
8489            "params": sub.params,
8490            "cursor": sub.cursor,
8491            "bootstrapState": sub.bootstrap_state,
8492            "status": sub.state.name(),
8493            "reasonCode": sub.reason_code,
8494            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8495            "syncedOnce": sub.synced_once,
8496        });
8497        self.conn.execute(
8498            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
8499            rusqlite::params![sub.id, sub.table, state.to_string()],
8500        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8501    }
8502
8503    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
8504        let ops: Vec<Value> = commit
8505            .ops
8506            .iter()
8507            .map(|op| {
8508                serde_json::json!({
8509                    "op": if op.upsert { "upsert" } else { "delete" },
8510                    "table": op.table,
8511                    "rowId": op.row_id,
8512                    "baseVersion": op.base_version,
8513                    "values": op.values.clone().map(Value::Object),
8514                })
8515            })
8516            .collect();
8517        self.conn
8518            .execute(
8519                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
8520                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
8521            )
8522            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8523        for operation in &commit.ops {
8524            let Some(values) = &operation.values else {
8525                continue;
8526            };
8527            let Some(table) = self.schema.table(&operation.table) else {
8528                continue;
8529            };
8530            for column in table
8531                .columns
8532                .iter()
8533                .filter(|column| column.ty == ColumnType::BlobRef)
8534            {
8535                let Some(Value::String(raw)) = values.get(&column.name) else {
8536                    continue;
8537                };
8538                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
8539                    continue;
8540                };
8541                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
8542                    continue;
8543                };
8544                self.conn
8545                    .execute(
8546                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
8547                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
8548                        rusqlite::params![commit.client_commit_id, blob_id],
8549                    )
8550                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8551            }
8552        }
8553        Ok(())
8554    }
8555
8556    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
8557        if let Some(commit) = self
8558            .outbox
8559            .iter()
8560            .find(|commit| commit.client_commit_id == client_commit_id)
8561        {
8562            for operation in &commit.ops {
8563                self.overlay_dirty.table(&operation.table);
8564            }
8565        }
8566
8567        self.conn
8568            .execute(
8569                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
8570                rusqlite::params![client_commit_id],
8571            )
8572            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8573        self.conn
8574            .execute(
8575                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
8576                rusqlite::params![client_commit_id],
8577            )
8578            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8579        self.conn.execute("DELETE FROM _syncular_row_deliveries WHERE NOT EXISTS(SELECT 1 FROM _syncular_outbox,json_each(ops_json) WHERE json_extract(json_each.value,'$.table')=tbl AND json_extract(json_each.value,'$.rowId')=id)", [])
8580            .map(|_| ())
8581            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8582    }
8583
8584    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
8585        match status {
8586            CommitOutcomeStatus::Applied => "applied",
8587            CommitOutcomeStatus::Cached => "cached",
8588            CommitOutcomeStatus::Conflict => "conflict",
8589            CommitOutcomeStatus::Rejected => "rejected",
8590        }
8591    }
8592
8593    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
8594        match resolution {
8595            CommitOutcomeResolution::Active => "active",
8596            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
8597            CommitOutcomeResolution::Superseded => "superseded",
8598            CommitOutcomeResolution::Dismissed => "dismissed",
8599        }
8600    }
8601
8602    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
8603        match value {
8604            "applied" => Ok(CommitOutcomeStatus::Applied),
8605            "cached" => Ok(CommitOutcomeStatus::Cached),
8606            "conflict" => Ok(CommitOutcomeStatus::Conflict),
8607            "rejected" => Ok(CommitOutcomeStatus::Rejected),
8608            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
8609        }
8610    }
8611
8612    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
8613        match value {
8614            "active" => Ok(CommitOutcomeResolution::Active),
8615            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
8616            "superseded" => Ok(CommitOutcomeResolution::Superseded),
8617            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
8618            _ => Err(format!(
8619                "invalid persisted commit outcome resolution {value:?}"
8620            )),
8621        }
8622    }
8623
8624    fn persist_commit_outcome(
8625        &self,
8626        client_commit_id: &str,
8627        status: CommitOutcomeStatus,
8628        results: &[CommitOperationOutcome],
8629        operations: Option<&[OutboxOp]>,
8630    ) -> Result<(), String> {
8631        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
8632        let operations_json = operations
8633            .map(|items| {
8634                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
8635            })
8636            .transpose()
8637            .map_err(|error| error.to_string())?;
8638        self.conn
8639            .execute(
8640                "INSERT INTO _syncular_commit_outcomes (
8641                   client_commit_id, status, recorded_at_ms, results_json,
8642                   operations_json, resolution
8643                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
8644                rusqlite::params![
8645                    client_commit_id,
8646                    Self::outcome_status_name(status),
8647                    self.clock_now_ms(),
8648                    results_json,
8649                    operations_json
8650                ],
8651            )
8652            .map(|_| ())
8653            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8654    }
8655
8656    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
8657        let StoredCommitOutcomeRow {
8658            sequence,
8659            client_commit_id,
8660            status,
8661            recorded_at_ms,
8662            results_json,
8663            operations_json,
8664            resolution,
8665            resolved_at_ms,
8666            replacement_client_commit_id,
8667        } = row;
8668        Ok(CommitOutcome {
8669            sequence,
8670            client_commit_id,
8671            status: Self::parse_outcome_status(&status)?,
8672            recorded_at_ms,
8673            results: serde_json::from_str(&results_json)
8674                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
8675            operations: operations_json
8676                .map(|value| {
8677                    serde_json::from_str(&value).map_err(|error| {
8678                        format!("invalid persisted commit outcome operations: {error}")
8679                    })
8680                })
8681                .transpose()?,
8682            retained_rows: None,
8683            resolution: Self::parse_outcome_resolution(&resolution)?,
8684            resolved_at_ms,
8685            replacement_client_commit_id,
8686        })
8687    }
8688
8689    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
8690    fn unique_conflicts(
8691        &self,
8692        table: &TableSchema,
8693        values: &Map<String, Value>,
8694        base: bool,
8695    ) -> Result<Vec<RetainedUniqueConflict>, String> {
8696        let source = if base {
8697            base_table(&table.name)
8698        } else {
8699            visible_table(&table.name)
8700        };
8701        let mut conflicts = Vec::new();
8702        for index in table.indexes.iter().filter(|index| index.unique) {
8703            let mut params = vec![json_param_to_sql(
8704                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
8705            )?];
8706            for name in &index.columns {
8707                let column = table
8708                    .columns
8709                    .iter()
8710                    .find(|column| column.name == *name)
8711                    .ok_or("sync.local_corrupt")?;
8712                let cell = json_to_column_value(column, values.get(name))?;
8713                params.push(owned_sql_value(RowParam::Cell(&cell))?);
8714            }
8715            let sql = format!(
8716                "SELECT * FROM {source} WHERE {} != ? AND {}",
8717                quote_ident(&table.primary_key),
8718                index
8719                    .columns
8720                    .iter()
8721                    .map(|column| format!("{} = ?", quote_ident(column)))
8722                    .collect::<Vec<_>>()
8723                    .join(" AND ")
8724            );
8725            let mut statement = self
8726                .conn
8727                .prepare_cached(&sql)
8728                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8729            let records = statement
8730                .query_map(rusqlite::params_from_iter(params), |record| {
8731                    let values = table
8732                        .columns
8733                        .iter()
8734                        .enumerate()
8735                        .map(|(index, column)| {
8736                            let value = sql_ref_to_json(column, record.get_ref(index)?);
8737                            json_to_column_value(column, Some(&value)).map_err(|message| {
8738                                rusqlite::Error::ToSqlConversionFailure(message.into())
8739                            })
8740                        })
8741                        .collect::<Result<Row, _>>()?;
8742                    Ok((values, record.get::<_, i64>(table.columns.len())?))
8743                })
8744                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8745            for record in records {
8746                let (row, version) =
8747                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8748                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
8749                conflicts.push(RetainedUniqueConflict {
8750                    index: index.name.clone(),
8751                    columns: index.columns.clone(),
8752                    row_id,
8753                    server_row: table
8754                        .columns
8755                        .iter()
8756                        .zip(&row)
8757                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8758                        .collect(),
8759                    server_version: version,
8760                });
8761            }
8762        }
8763        Ok(conflicts)
8764    }
8765
8766    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
8767        if let Some((commit, initial)) = self
8768            .failed_commits
8769            .iter()
8770            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
8771        {
8772            let mut rows = Vec::new();
8773            for operation in &commit.ops {
8774                let table = self
8775                    .schema
8776                    .table(&operation.table)
8777                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
8778                let key =
8779                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8780                        .map_err(|_| "sync.local_corrupt".to_owned())?;
8781                let base = self.stored_row(table, &operation.row_id, true);
8782                rows.push(RetainedCommitRow {
8783                    table: operation.table.clone(),
8784                    row_id: operation.row_id.clone(),
8785                    unique_conflicts: if operation.upsert {
8786                        self.unique_conflicts(
8787                            table,
8788                            initial
8789                                .get(&key)
8790                                .and_then(Value::as_object)
8791                                .or(operation.values.as_ref())
8792                                .ok_or("sync.local_corrupt")?,
8793                            true,
8794                        )?
8795                    } else {
8796                        Vec::new()
8797                    },
8798                    local_row: if operation.upsert {
8799                        initial
8800                            .get(&key)
8801                            .and_then(Value::as_object)
8802                            .cloned()
8803                            .or_else(|| operation.values.clone())
8804                    } else {
8805                        None
8806                    },
8807                    server_row: base.as_ref().map(|(values, _)| {
8808                        table
8809                            .columns
8810                            .iter()
8811                            .zip(values)
8812                            .map(|(column, value)| {
8813                                (column.name.clone(), column_value_to_json(value))
8814                            })
8815                            .collect()
8816                    }),
8817                    server_version: base.map(|(_, version)| version),
8818                });
8819            }
8820            outcome.retained_rows = Some(rows);
8821        }
8822        Ok(outcome)
8823    }
8824
8825    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
8826        let row = self
8827            .conn
8828            .query_row(
8829                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8830                        resolution, resolved_at_ms, replacement_client_commit_id
8831                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
8832                rusqlite::params![client_commit_id],
8833                |row| {
8834                    Ok(StoredCommitOutcomeRow {
8835                        sequence: row.get(0)?,
8836                        client_commit_id: row.get(1)?,
8837                        status: row.get(2)?,
8838                        recorded_at_ms: row.get(3)?,
8839                        results_json: row.get(4)?,
8840                        operations_json: row.get(5)?,
8841                        resolution: row.get(6)?,
8842                        resolved_at_ms: row.get(7)?,
8843                        replacement_client_commit_id: row.get(8)?,
8844                    })
8845                },
8846            )
8847            .optional()
8848            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8849        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
8850            .transpose()
8851    }
8852
8853    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
8854        if query.limit == Some(0) {
8855            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
8856        }
8857        let mut sql = String::from(
8858            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8859                    resolution, resolved_at_ms, replacement_client_commit_id
8860               FROM _syncular_commit_outcomes",
8861        );
8862        if query.active_only {
8863            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
8864        }
8865        sql.push_str(" ORDER BY seq DESC");
8866        if let Some(limit) = query.limit {
8867            sql.push_str(&format!(" LIMIT {limit}"));
8868        }
8869        let mut stmt = self
8870            .conn
8871            .prepare(&sql)
8872            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8873        let rows = stmt
8874            .query_map([], |row| {
8875                Ok(StoredCommitOutcomeRow {
8876                    sequence: row.get(0)?,
8877                    client_commit_id: row.get(1)?,
8878                    status: row.get(2)?,
8879                    recorded_at_ms: row.get(3)?,
8880                    results_json: row.get(4)?,
8881                    operations_json: row.get(5)?,
8882                    resolution: row.get(6)?,
8883                    resolved_at_ms: row.get(7)?,
8884                    replacement_client_commit_id: row.get(8)?,
8885                })
8886            })
8887            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8888        let mut outcomes = Vec::new();
8889        for row in rows {
8890            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
8891                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
8892            )?)?);
8893        }
8894        Ok(outcomes)
8895    }
8896
8897    fn prune_commit_outcomes(&self) -> Result<(), String> {
8898        #[cfg(test)]
8899        self.outcome_prune_count
8900            .set(self.outcome_prune_count.get() + 1);
8901        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
8902        let count = self
8903            .conn
8904            .query_row(
8905                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
8906                [],
8907                |row| row.get::<_, i64>(0),
8908            )
8909            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8910            as usize;
8911        let excess = count.saturating_sub(max_entries);
8912        if excess == 0 {
8913            return Ok(());
8914        }
8915        let mut stmt = self
8916            .conn
8917            .prepare(
8918                "SELECT seq FROM _syncular_commit_outcomes
8919                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
8920                  ORDER BY seq ASC LIMIT ?1",
8921            )
8922            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8923        let rows = stmt
8924            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
8925            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8926        let sequences = rows
8927            .collect::<Result<Vec<_>, _>>()
8928            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8929        drop(stmt);
8930        for sequence in sequences {
8931            self.conn
8932                .execute(
8933                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
8934                    rusqlite::params![sequence],
8935                )
8936                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8937        }
8938        Ok(())
8939    }
8940
8941    // -- driver surface ---------------------------------------------------------
8942
8943    pub fn subscribe(
8944        &mut self,
8945        id: String,
8946        table: String,
8947        scopes: Vec<(String, Vec<String>)>,
8948        params: Option<String>,
8949    ) -> Result<(), String> {
8950        if self.schema.table(&table).is_none() {
8951            return Err(format!("unknown table {table:?}"));
8952        }
8953        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
8954            let same_intent = existing.table == table
8955                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
8956                && existing.params == params;
8957            if same_intent {
8958                return Ok(());
8959            }
8960            return Err(
8961                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
8962                    .to_owned(),
8963            );
8964        }
8965        let sub = Subscription {
8966            id: id.clone(),
8967            table,
8968            requested: scopes,
8969            params,
8970            cursor: -1,
8971            bootstrap_state: None,
8972            state: SubState::Active,
8973            reason_code: None,
8974            effective: None,
8975            synced_once: false,
8976        };
8977        self.persist_sub(&sub)?;
8978        self.subs.push(sub);
8979        Ok(())
8980    }
8981
8982    pub fn unsubscribe(&mut self, id: &str) {
8983        if self.subs.iter().any(|s| s.id == id) {
8984            self.cancel_sync_round();
8985        }
8986        self.subs.retain(|s| s.id != id);
8987        let _ = self.conn.execute(
8988            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8989            rusqlite::params![id],
8990        );
8991    }
8992
8993    // -- windowed subscriptions (§4.8) ------------------------------------------
8994
8995    /// §4.8: set the live window units for a base — a value-sharded family
8996    /// of subscriptions, one per unit. Added units get fresh subscriptions
8997    /// (image-lane bootstrap on the next sync); removed units are
8998    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
8999    /// Idempotent; re-entry cancels any deferred eviction.
9000    pub fn set_window(
9001        &mut self,
9002        base: &WindowBase,
9003        units: &[String],
9004    ) -> Result<CommandEffects, String> {
9005        let table = self
9006            .schema
9007            .table(&base.table)
9008            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
9009        if table.scope_column(&base.variable).is_none() {
9010            return Err(format!(
9011                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
9012                base.table, base.variable
9013            ));
9014        }
9015        let base_key = window_base_key(base);
9016        let wanted: std::collections::HashSet<&String> = units.iter().collect();
9017        let live = self.load_window_units(&base_key);
9018        self.begin_observation("syncular_window")?;
9019        let mut batch = ChangeAccumulator::default();
9020        let mut changed = false;
9021
9022        let prior_subs = self.subs.clone();
9023        let widened = (|| {
9024            // Widen: units wanted but not live → fresh subscription + registry row.
9025            for unit in units {
9026                if live.iter().any(|(u, _)| u == unit) {
9027                    continue;
9028                }
9029                let sub_id = derive_sub_id(base, unit);
9030                self.delete_pending_evict(&sub_id)?;
9031                self.insert_window_unit(&base_key, unit, &sub_id)?;
9032                self.subscribe(
9033                    sub_id,
9034                    base.table.clone(),
9035                    unit_scopes(base, unit),
9036                    base.params.clone(),
9037                )?;
9038                batch.window(&base_key, &base.table, unit);
9039                changed = true;
9040            }
9041
9042            self.finish_observation("syncular_window", batch)
9043        })();
9044        if let Err(error) = widened {
9045            self.rollback_observation("syncular_window");
9046            self.subs = prior_subs;
9047            return Err(error);
9048        }
9049        for (unit, sub_id) in live {
9050            if wanted.contains(&unit) {
9051                continue;
9052            }
9053            self.rebuild_overlay_if_dirty();
9054            let prior_subs = self.subs.clone();
9055            self.begin_observation("syncular_window")?;
9056            let effective = self
9057                .subs
9058                .iter()
9059                .find(|sub| sub.id == sub_id)
9060                .and_then(|sub| sub.effective.clone())
9061                .unwrap_or_else(|| unit_scopes(base, &unit));
9062            let mut batch = ChangeAccumulator::default();
9063            self.record_scope_map(&mut batch, &base.table, &effective);
9064            batch.window(&base_key, &base.table, &unit);
9065            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
9066            let remaining = match result {
9067                Ok(remaining) => remaining,
9068                Err(error) => {
9069                    self.rollback_observation("syncular_window");
9070                    self.subs = prior_subs;
9071                    return Err(error);
9072                }
9073            };
9074            if let Err(error) = self.finish_observation("syncular_window", batch) {
9075                self.rollback_observation("syncular_window");
9076                self.subs = prior_subs;
9077                return Err(error);
9078            }
9079            if remaining {
9080                self.drain_pending_evictions()?;
9081            }
9082            changed = true;
9083        }
9084        Ok(if changed {
9085            CommandEffects::interactive()
9086        } else {
9087            CommandEffects::none()
9088        })
9089    }
9090
9091    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
9092    /// the subset still bootstrap-pending. Registration alone is not
9093    /// completeness — a unit is pending until its subscription completes a
9094    /// bootstrap round (cursor advances past -1 with no resume token held).
9095    pub fn window_state(&self, base: &WindowBase) -> WindowState {
9096        let mut units = Vec::new();
9097        let mut pending = Vec::new();
9098        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
9099            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
9100                Some(sub) => {
9101                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
9102                }
9103                None => true,
9104            };
9105            if is_pending {
9106                pending.push(unit.clone());
9107            }
9108            units.push(unit);
9109        }
9110        WindowState { units, pending }
9111    }
9112
9113    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
9114        let mut stmt = match self
9115            .conn
9116            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
9117        {
9118            Ok(stmt) => stmt,
9119            Err(_) => return Vec::new(),
9120        };
9121        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
9122            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9123        });
9124        match rows {
9125            Ok(rows) => rows.filter_map(Result::ok).collect(),
9126            Err(_) => Vec::new(),
9127        }
9128    }
9129
9130    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
9131        let mut stmt = match self.conn.prepare(
9132            "SELECT windows.base, windows.unit, subscriptions.tbl
9133               FROM _syncular_windows AS windows
9134               JOIN _syncular_subscriptions AS subscriptions
9135                 ON subscriptions.id = windows.sub_id
9136               ORDER BY windows.base, windows.unit",
9137        ) {
9138            Ok(stmt) => stmt,
9139            Err(_) => return Vec::new(),
9140        };
9141        let rows = stmt.query_map([], |row| {
9142            Ok((
9143                row.get::<_, String>(0)?,
9144                row.get::<_, String>(1)?,
9145                row.get::<_, String>(2)?,
9146            ))
9147        });
9148        match rows {
9149            Ok(rows) => rows.filter_map(Result::ok).collect(),
9150            Err(_) => Vec::new(),
9151        }
9152    }
9153
9154    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
9155        self.conn
9156            .query_row(
9157                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
9158                rusqlite::params![sub_id],
9159                |row| Ok((row.get(0)?, row.get(1)?)),
9160            )
9161            .ok()
9162    }
9163
9164    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
9165        self.conn
9166            .execute(
9167                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
9168                rusqlite::params![base_key, unit, sub_id],
9169            )
9170            .map(|_| ())
9171            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9172    }
9173
9174    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
9175        self.conn
9176            .execute(
9177                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
9178                rusqlite::params![base_key, unit],
9179            )
9180            .map(|_| ())
9181            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9182    }
9183
9184    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
9185    /// Deletes the unit's rows except those pinned by a pending outbox
9186    /// commit (E1); records a deferred eviction if any pin remains; discards
9187    /// the subscription's cursor/resume/effective-echo (E3) and version
9188    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
9189    /// nothing.
9190    fn evict_unit(
9191        &mut self,
9192        base_key: &str,
9193        base: &WindowBase,
9194        unit: &str,
9195        sub_id: &str,
9196    ) -> Result<bool, String> {
9197        let effective = self
9198            .subs
9199            .iter()
9200            .find(|s| s.id == sub_id)
9201            .and_then(|s| s.effective.clone())
9202            .unwrap_or_else(|| unit_scopes(base, unit));
9203        let pinned = self.pinned_row_ids(&base.table);
9204        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
9205        self.delete_window_unit(base_key, unit)?;
9206        self.conn
9207            .execute(
9208                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
9209                [sub_id],
9210            )
9211            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9212        self.subs.retain(|sub| sub.id != sub_id);
9213        if deferred {
9214            self.save_pending_evict(sub_id, &base.table, &effective)?;
9215        } else {
9216            self.delete_pending_evict(sub_id)?;
9217        }
9218        Ok(remaining)
9219    }
9220
9221    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
9222    /// primary keys. Returns (more unpinned work, any scoped rows remain).
9223    /// A missing scope-column mapping fails without deleting rows.
9224    fn evict_scope_rows(
9225        &self,
9226        table_name: &str,
9227        effective: &[(String, Vec<String>)],
9228        pinned: &std::collections::HashSet<String>,
9229    ) -> Result<(bool, bool), String> {
9230        if effective.is_empty() {
9231            return Ok((false, false));
9232        }
9233        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
9234        let mut clauses = Vec::new();
9235        let mut params: Vec<SqlValue> = Vec::new();
9236        for (variable, values) in effective {
9237            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
9238            if values.is_empty() {
9239                return Ok((false, false));
9240            }
9241            let holes = values
9242                .iter()
9243                .map(|v| {
9244                    params.push(SqlValue::Text(v.clone()));
9245                    "?"
9246                })
9247                .collect::<Vec<_>>()
9248                .join(", ");
9249            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
9250        }
9251        let scope = clauses.join(" AND ");
9252        let scope_params = params.len();
9253        let pk = quote_ident(&table.primary_key);
9254        if !pinned.is_empty() {
9255            let holes = pinned
9256                .iter()
9257                .map(|id| {
9258                    params.push(SqlValue::Text(id.clone()));
9259                    "?"
9260                })
9261                .collect::<Vec<_>>()
9262                .join(", ");
9263            clauses.push(format!("{pk} NOT IN ({holes})"));
9264        }
9265        let predicate = clauses.join(" AND ");
9266        let base = base_table(table_name);
9267        let visible = visible_table(table_name);
9268        let mut ids: Vec<SqlValue> = Vec::new();
9269        for source in [&base, &visible] {
9270            let mut stmt = self
9271                .conn
9272                .prepare_cached(&format!(
9273                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
9274                ))
9275                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9276            ids = stmt
9277                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
9278                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9279                .collect::<Result<_, _>>()
9280                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9281            if !ids.is_empty() {
9282                break;
9283            }
9284        }
9285        if !ids.is_empty() {
9286            for id in &ids {
9287                self.conn
9288                    .execute(
9289                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
9290                        rusqlite::params![table_name, id],
9291                    )
9292                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9293            }
9294            let holes = vec!["?"; ids.len()].join(", ");
9295            for source in [&base, &visible] {
9296                self.conn
9297                    .execute(
9298                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
9299                        rusqlite::params_from_iter(&ids),
9300                    )
9301                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9302            }
9303        }
9304        let mut remaining = false;
9305        let mut deferred = false;
9306        for source in [&base, &visible] {
9307            remaining |= self
9308                .conn
9309                .query_row(
9310                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
9311                    rusqlite::params_from_iter(&params),
9312                    |row| row.get::<_, bool>(0),
9313                )
9314                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9315            deferred |= self
9316                .conn
9317                .query_row(
9318                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
9319                    rusqlite::params_from_iter(&params[..scope_params]),
9320                    |row| row.get::<_, bool>(0),
9321                )
9322                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9323        }
9324        Ok((remaining, deferred))
9325    }
9326
9327    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
9328    fn drain_pending_evictions(&mut self) -> Result<(), String> {
9329        let pending = self.load_pending_evictions()?;
9330        if pending.is_empty() {
9331            return Ok(());
9332        }
9333        self.rebuild_overlay_if_dirty();
9334        for (sub_id, table_name, effective) in pending {
9335            if self.schema.table(&table_name).is_none() {
9336                self.delete_pending_evict(&sub_id)?;
9337                continue;
9338            }
9339            loop {
9340                std::thread::yield_now();
9341                self.begin_observation("syncular_evict")?;
9342                let pinned = self.pinned_row_ids(&table_name);
9343                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
9344                let (remaining, deferred) = match result {
9345                    Ok(result) => result,
9346                    Err(error) => {
9347                        self.rollback_observation("syncular_evict");
9348                        return Err(error);
9349                    }
9350                };
9351                if !deferred {
9352                    if let Err(error) = self.delete_pending_evict(&sub_id) {
9353                        self.rollback_observation("syncular_evict");
9354                        return Err(error);
9355                    }
9356                }
9357                let mut batch = ChangeAccumulator::default();
9358                self.record_scope_map(&mut batch, &table_name, &effective);
9359                if let Err(error) = self.finish_observation("syncular_evict", batch) {
9360                    self.rollback_observation("syncular_evict");
9361                    return Err(error);
9362                }
9363                if !remaining {
9364                    break;
9365                }
9366            }
9367        }
9368        Ok(())
9369    }
9370
9371    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
9372    /// commit — rows that MUST NOT be evicted until the commit drains.
9373    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
9374        let mut pinned = std::collections::HashSet::new();
9375        for commit in self
9376            .outbox
9377            .iter()
9378            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
9379        {
9380            for op in &commit.ops {
9381                if op.table == table {
9382                    pinned.insert(op.row_id.clone());
9383                }
9384            }
9385        }
9386        pinned
9387    }
9388
9389    fn save_pending_evict(
9390        &self,
9391        sub_id: &str,
9392        table: &str,
9393        effective: &[(String, Vec<String>)],
9394    ) -> Result<(), String> {
9395        self.conn.execute(
9396            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
9397            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
9398        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9399    }
9400
9401    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
9402        self.conn
9403            .execute(
9404                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
9405                rusqlite::params![sub_id],
9406            )
9407            .map(|_| ())
9408            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9409    }
9410
9411    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
9412        let mut stmt = self
9413            .conn
9414            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
9415            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9416        let rows = stmt
9417            .query_map([], |row| {
9418                Ok((
9419                    row.get::<_, String>(0)?,
9420                    row.get::<_, String>(1)?,
9421                    row.get::<_, String>(2)?,
9422                ))
9423            })
9424            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9425        rows.map(|row| {
9426            let (sub_id, table, json) =
9427                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9428            let value: Value = serde_json::from_str(&json)
9429                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
9430            let effective = json_to_scope_map(&value)
9431                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
9432            Ok((sub_id, table, effective))
9433        })
9434        .collect()
9435    }
9436
9437    /// Select failed-intent retention; reload durable retained aggregates.
9438    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
9439        self.retain_failed_commits = enabled;
9440        for (commit, _) in &self.failed_commits {
9441            for operation in &commit.ops {
9442                self.overlay_dirty.table(&operation.table);
9443            }
9444        }
9445        self.failed_commits = self.load_failed_commits()?;
9446        for (commit, _) in &self.failed_commits {
9447            for operation in &commit.ops {
9448                self.overlay_dirty.table(&operation.table);
9449            }
9450        }
9451        Ok(())
9452    }
9453
9454    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
9455        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
9456            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9457        let rows = statement
9458            .query_map([], |row| {
9459                Ok((
9460                    row.get::<_, String>(0)?,
9461                    row.get::<_, String>(1)?,
9462                    row.get::<_, String>(2)?,
9463                ))
9464            })
9465            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9466        rows.map(|row| {
9467            let (id, operations, initial) =
9468                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9469            let operations: Vec<CommitOperation> =
9470                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
9471            let initial =
9472                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
9473            Ok((
9474                OutboxCommit {
9475                    client_commit_id: id,
9476                    ops: operations
9477                        .into_iter()
9478                        .map(|op| OutboxOp {
9479                            upsert: op.op == "upsert",
9480                            table: op.table,
9481                            row_id: op.row_id,
9482                            base_version: op.base_version,
9483                            values: op.values,
9484                        })
9485                        .collect(),
9486                },
9487                initial,
9488            ))
9489        })
9490        .collect()
9491    }
9492
9493    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
9494        let mut initial = Map::new();
9495        for operation in operations {
9496            let table = self
9497                .schema
9498                .table(&operation.table)
9499                .ok_or_else(|| "sync.unknown_table".to_owned())?;
9500            if let Some((values, _)) = self
9501                .stored_row(table, &operation.row_id, true)
9502                .or_else(|| self.visible_row(table, &operation.row_id))
9503            {
9504                let mut intended: Map<String, Value> = table
9505                    .columns
9506                    .iter()
9507                    .zip(values.iter())
9508                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
9509                    .collect();
9510                if let Some(patch) = &operation.values {
9511                    intended.extend(patch.clone());
9512                }
9513                initial.insert(
9514                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
9515                        .map_err(|_| "sync.local_corrupt".to_owned())?,
9516                    Value::Object(intended),
9517                );
9518            }
9519        }
9520        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
9521            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
9522            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
9523            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9524        self.failed_commits.push((
9525            OutboxCommit {
9526                client_commit_id: id.to_owned(),
9527                ops: operations.to_vec(),
9528            },
9529            initial,
9530        ));
9531        Ok(())
9532    }
9533
9534    fn drop_failed_commits(
9535        &mut self,
9536        matches: impl Fn(&str, &Map<String, Value>) -> bool,
9537    ) -> Result<Vec<OutboxCommit>, String> {
9538        let mut doomed = self.drop_acknowledged_rows(&matches)?;
9539        for (commit, initial) in &self.failed_commits {
9540            for operation in &commit.ops {
9541                let key =
9542                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
9543                        .map_err(|_| "sync.local_corrupt".to_owned())?;
9544                if initial
9545                    .get(&key)
9546                    .and_then(Value::as_object)
9547                    .is_some_and(|values| matches(&operation.table, values))
9548                {
9549                    doomed.push(commit.clone());
9550                    break;
9551                }
9552            }
9553        }
9554        for commit in &doomed {
9555            self.conn
9556                .execute(
9557                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9558                    rusqlite::params![commit.client_commit_id],
9559                )
9560                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9561        }
9562        for commit in &doomed {
9563            self.conn.execute("UPDATE _syncular_commit_outcomes SET operations_json = NULL, results_json = '[]' WHERE client_commit_id = ?1", rusqlite::params![commit.client_commit_id]).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9564        }
9565        self.conflicts.retain(|record| {
9566            !doomed
9567                .iter()
9568                .any(|commit| commit.client_commit_id == record.client_commit_id)
9569        });
9570        self.rejections.retain(|record| {
9571            !doomed
9572                .iter()
9573                .any(|commit| commit.client_commit_id == record.client_commit_id)
9574        });
9575        self.failed_commits.retain(|(commit, _)| {
9576            !doomed
9577                .iter()
9578                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
9579        });
9580        for commit in &doomed {
9581            for operation in &commit.ops {
9582                self.overlay_dirty.table(&operation.table);
9583            }
9584        }
9585        Ok(doomed)
9586    }
9587
9588    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
9589        if mutations.is_empty() {
9590            return Err("a commit must contain at least one operation (§6.1)".to_owned());
9591        }
9592        let mut ops = Vec::with_capacity(mutations.len());
9593        for mutation in mutations {
9594            match mutation {
9595                Mutation::Patch {
9596                    table,
9597                    values,
9598                    base_version,
9599                } => {
9600                    let schema_table = self
9601                        .schema
9602                        .table(&table)
9603                        .ok_or_else(|| "sync.unknown_table".to_owned())?;
9604                    let values = normalize_values_casing(schema_table, values)?;
9605                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9606                    ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
9607                }
9608                Mutation::Upsert {
9609                    table,
9610                    values,
9611                    base_version,
9612                } => {
9613                    let schema_table = self
9614                        .schema
9615                        .table(&table)
9616                        .ok_or_else(|| format!("unknown table {table:?}"))?;
9617                    // §5: value keys are accepted in snake_case AND the
9618                    // generated row types' camelCase; normalize to SQL truth
9619                    // before the pk lookup / codec see them.
9620                    let values = normalize_values_casing(schema_table, values)?;
9621                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9622                    // §6.7 `mutate` marks every column present: fill missing
9623                    // nullable columns with NULL before the payload is built.
9624                    let values = full_row_values(schema_table, values)?;
9625                    // §5.11: validate the payload encodes with the current
9626                    // codec. A key-selection or unknown-key failure is a
9627                    // durable rejection at the push seam (§10.3), never an
9628                    // author-time error. Full-row `mutate` presents every
9629                    // column, so no stored fallback is needed.
9630                    self.validate_author_encode(schema_table, &row_id, &values)?;
9631                    ops.push(OutboxOp {
9632                        upsert: true,
9633                        table,
9634                        row_id,
9635                        base_version,
9636                        values: Some(values),
9637                    });
9638                }
9639                Mutation::Delete {
9640                    table,
9641                    row_id,
9642                    base_version,
9643                } => {
9644                    if self.schema.table(&table).is_none() {
9645                        return Err(format!("unknown table {table:?}"));
9646                    }
9647                    ops.push(OutboxOp {
9648                        upsert: false,
9649                        table,
9650                        row_id,
9651                        base_version,
9652                        values: None,
9653                    });
9654                }
9655            }
9656        }
9657        self.record_outbox_commit(ops)
9658    }
9659
9660    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
9661        let commit = OutboxCommit {
9662            client_commit_id: uuid::Uuid::new_v4().to_string(),
9663            ops,
9664        };
9665        self.begin_observation("syncular_mutation")?;
9666        let mut batch = ChangeAccumulator::default();
9667        for op in &commit.ops {
9668            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
9669            if let Some(values) = &op.values {
9670                if let Some(table) = self.schema.table(&op.table) {
9671                    for scope in &table.scope_variables {
9672                        if let Some(Value::String(value)) = values.get(&scope.column) {
9673                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
9674                            precise = true;
9675                        }
9676                    }
9677                }
9678            }
9679            if !precise {
9680                batch.table(&op.table);
9681            }
9682        }
9683        if let Err(error) = self.persist_outbox_insert(&commit) {
9684            self.rollback_observation("syncular_mutation");
9685            return Err(error);
9686        }
9687        let was_dirty = self.overlay_dirty.snapshot();
9688        // A clean overlay contains the FIFO fold of every previous commit, so
9689        // the new commit applies over it. §7.1: an operation that does not
9690        // apply (a secondary unique collision) fails the whole commit; the
9691        // savepoint rollback removes the outbox entry and every visible write.
9692        if self.overlay_dirty.get() {
9693            self.rebuild_overlay();
9694        }
9695        if let Some(error) = commit
9696            .ops
9697            .iter()
9698            .find_map(|op| self.apply_outbox_op(op).err())
9699        {
9700            self.rollback_observation("syncular_mutation");
9701            self.overlay_dirty.restore(was_dirty.clone());
9702            return Err(error);
9703        }
9704        let id = commit.client_commit_id.clone();
9705        self.outbox.push(commit);
9706        batch.status = true;
9707        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
9708            self.rollback_observation("syncular_mutation");
9709            self.outbox.pop();
9710            self.overlay_dirty.restore(was_dirty.clone());
9711            return Err(error);
9712        }
9713        Ok(id)
9714    }
9715
9716    /// Record one §6.1 sparse upsert: the primary key plus the supplied
9717    /// non-scope columns are present; every other column is absent and stays
9718    /// untouched on the server and in the local overlay. An absent local base
9719    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
9720    pub fn patch(
9721        &mut self,
9722        table: &str,
9723        row_id: &str,
9724        partial: Map<String, Value>,
9725        base_version: Option<i64>,
9726    ) -> Result<String, String> {
9727        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
9728        self.record_outbox_commit(vec![operation])
9729    }
9730
9731    fn prepare_patch(
9732        &self,
9733        table: &str,
9734        row_id: &str,
9735        partial: Map<String, Value>,
9736        base_version: Option<i64>,
9737    ) -> Result<OutboxOp, String> {
9738        let schema_table = self
9739            .schema
9740            .table(table)
9741            .ok_or_else(|| format!("unknown table {table:?}"))?;
9742        let partial = normalize_values_casing(schema_table, partial)?;
9743        if let Some(pk) = partial.get(&schema_table.primary_key) {
9744            if pk.as_str() != Some(row_id) {
9745                return Err(format!(
9746                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
9747                ));
9748            }
9749        }
9750        // Existence reads only the key. Locked encrypted values stay opaque;
9751        // decoding the complete row here would reject a valid plain patch.
9752        let exists: bool = self
9753            .conn
9754            .query_row(
9755                &format!(
9756                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
9757                    visible_table(table),
9758                    row_id_predicate(schema_table)
9759                ),
9760                rusqlite::params![row_id],
9761                |row| row.get(0),
9762            )
9763            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9764        if !exists {
9765            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
9766        }
9767        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
9768        // server accepts a present scope column whose value equals the
9769        // stored row and applies it as a no-op, so a decoded envelope
9770        // round-trips. The local row is the only value the client can prove
9771        // equality against; when the row is absent locally the client cannot
9772        // prove equality against the server's stored row and fails closed.
9773        // A primary key that is also a scope column needs no stored-row
9774        // read: the primary key in a sparse payload is by construction the
9775        // row id being patched (§6.1), so its value is proven equal already.
9776        for scope in &schema_table.scope_variables {
9777            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
9778                continue;
9779            }
9780            let Some(column) = schema_table
9781                .columns
9782                .iter()
9783                .find(|column| column.name == scope.column)
9784            else {
9785                continue;
9786            };
9787            let stored = self
9788                .conn
9789                .query_row(
9790                    &format!(
9791                        "SELECT {} FROM {} WHERE {}",
9792                        quote_ident(&scope.column),
9793                        visible_table(&schema_table.name),
9794                        row_id_predicate(schema_table)
9795                    ),
9796                    rusqlite::params![row_id],
9797                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
9798                )
9799                .ok();
9800            // Decode the supplied value through the same column-type path the
9801            // rest of the patch encoding uses, so a value that only equals
9802            // the stored one in another number representation still compares
9803            // equal.
9804            let incoming = json_to_column_value(column, partial.get(&scope.column))
9805                .ok()
9806                .map(|value| column_value_to_json(&value));
9807            // A stored blob never equals a patch value, matching the server's
9808            // scope-column comparison (§3.4): bytes are unstorable as scope
9809            // values.
9810            let equal = stored.as_ref().is_some_and(|stored| {
9811                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
9812            });
9813            if !equal {
9814                return Err(format!(
9815                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
9816                    scope.column
9817                ));
9818            }
9819        }
9820        let mut values = partial;
9821        // Presence-set semantics: a proven-equal scope column is dropped, so
9822        // the patch leaves the stored value untouched (§6.2).
9823        for scope in &schema_table.scope_variables {
9824            values.remove(&scope.column);
9825        }
9826        values.insert(
9827            schema_table.primary_key.clone(),
9828            Value::from(row_id.to_owned()),
9829        );
9830        // §5.11: structural validation only; an absent key-id selector is
9831        // resolved at the push seam from the stored local row, and a
9832        // key-selection or unknown-key failure becomes a durable
9833        // `client.encrypt_failed` rejection there (§10.3), never an
9834        // author-time error.
9835        self.validate_author_encode(schema_table, row_id, &values)?;
9836        Ok(OutboxOp {
9837            upsert: true,
9838            table: table.to_owned(),
9839            row_id: row_id.to_owned(),
9840            base_version,
9841            values: Some(values),
9842        })
9843    }
9844
9845    /// §5.11 key-id fallback: the stored local row's selector value as a
9846    /// positional row (only the selector slot filled). Returns None when no
9847    /// selector is configured, the row is locally absent, or the stored
9848    /// value is not a usable key id.
9849    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
9850        let selector = self.encryption.key_id_columns.get(&table.name)?;
9851        let index = table
9852            .columns
9853            .iter()
9854            .position(|column| &column.name == selector)?;
9855        let sql = format!(
9856            "SELECT {} FROM {} WHERE {}",
9857            quote_ident(selector),
9858            visible_table(&table.name),
9859            row_id_predicate(table)
9860        );
9861        let key: Option<String> = self
9862            .conn
9863            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
9864            .ok()?;
9865        let key = key.filter(|key| !key.is_empty())?;
9866        let mut fallback: Row = vec![None; table.columns.len()];
9867        fallback[index] = Some(ColumnValue::String(key));
9868        Some(fallback)
9869    }
9870
9871    /// §5.11/§10.3 author-time encode check: a structural failure (a
9872    /// non-nullable NULL, a value the codec rejects) is an author-time
9873    /// error; a key-selection or unknown-key failure is deferred to the push
9874    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
9875    fn validate_author_encode(
9876        &self,
9877        table: &TableSchema,
9878        row_id: &str,
9879        values: &Map<String, Value>,
9880    ) -> Result<(), String> {
9881        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
9882            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
9883            result => result.map(|_| ()),
9884        }
9885    }
9886
9887    /// §5.11/§10.3: drop every pending commit whose push encode fails with
9888    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
9889    /// raise one durable rejection per commit. `sync()` never aborts for an
9890    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
9891    /// undoing the dropped commit's purely-optimistic rows.
9892    fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
9893        if !self
9894            .schema
9895            .tables
9896            .iter()
9897            .any(|table| table.has_encrypted_columns())
9898        {
9899            return Ok(false);
9900        }
9901        let failures = self
9902            .outbox
9903            .iter()
9904            .filter_map(|commit| {
9905                commit
9906                    .ops
9907                    .iter()
9908                    .find_map(|op| {
9909                        let values = op.values.as_ref().filter(|_| op.upsert)?;
9910                        let table = self.schema.table(&op.table)?;
9911                        let fallback = self.stored_key_fallback(table, &op.row_id);
9912                        match encode_sparse_row_json(
9913                            table,
9914                            &op.row_id,
9915                            values,
9916                            &self.encryption,
9917                            fallback.as_ref(),
9918                        ) {
9919                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
9920                            _ => None,
9921                        }
9922                    })
9923                    .map(|error| (commit.clone(), error))
9924            })
9925            .collect::<Vec<_>>();
9926        if failures.is_empty() {
9927            return Ok(false);
9928        }
9929        for (commit, message) in failures {
9930            if self.retain_failed_commits {
9931                self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
9932            }
9933            let rejection = RejectionRecord {
9934                client_commit_id: commit.client_commit_id.clone(),
9935                op_index: 0,
9936                code: ENCRYPT_FAILED_CODE.to_owned(),
9937                message,
9938                retryable: false,
9939                details: None,
9940                operation: commit.ops.first().map(CommitOperation::from),
9941            };
9942            self.persist_commit_outcome(
9943                &commit.client_commit_id,
9944                CommitOutcomeStatus::Rejected,
9945                &[CommitOperationOutcome::Error {
9946                    rejection: rejection.clone(),
9947                }],
9948                Some(&commit.ops),
9949            )?;
9950            self.delete_outbox_persisted(&commit.client_commit_id)?;
9951            self.outbox
9952                .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
9953            self.rejections.push(rejection);
9954        }
9955        self.prune_commit_outcomes()?;
9956        self.rebuild_overlay_if_dirty();
9957        Ok(true)
9958    }
9959
9960    pub fn pending_commit_ids(&self) -> Vec<String> {
9961        self.outbox
9962            .iter()
9963            .map(|c| c.client_commit_id.clone())
9964            .collect()
9965    }
9966
9967    /// §6.1: the sparse push payload bytes of every pending upsert operation,
9968    /// FIFO — exactly what the next push round would put on the wire. Used by
9969    /// conformance B.21(h) to pin cross-core byte identity.
9970    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
9971        let mut payloads = Vec::new();
9972        for commit in &self.outbox {
9973            for op in &commit.ops {
9974                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
9975                    continue;
9976                };
9977                let Some(table) = self.schema.table(&op.table) else {
9978                    continue;
9979                };
9980                let fallback = self.stored_key_fallback(table, &op.row_id);
9981                if let Ok(payload) = encode_sparse_row_json(
9982                    table,
9983                    &op.row_id,
9984                    values,
9985                    &self.encryption,
9986                    fallback.as_ref(),
9987                ) {
9988                    payloads.push(payload);
9989                }
9990            }
9991        }
9992        payloads
9993    }
9994
9995    pub fn conflicts(&self) -> &[ConflictRecord] {
9996        &self.conflicts
9997    }
9998
9999    pub fn rejections(&self) -> &[RejectionRecord] {
10000        &self.rejections
10001    }
10002
10003    pub fn resolve_commit_outcome(
10004        &mut self,
10005        input: ResolveCommitOutcomeInput,
10006    ) -> Result<CommitOutcome, String> {
10007        let current = self
10008            .commit_outcome(&input.client_commit_id)?
10009            .ok_or_else(|| {
10010                format!(
10011                    "sync.outcome_not_found: no durable outcome exists for {:?}",
10012                    input.client_commit_id
10013                )
10014            })?;
10015        if current.resolution != CommitOutcomeResolution::Active {
10016            return Ok(current);
10017        }
10018        if input.resolution == CommitOutcomeResolution::Active {
10019            return Err("sync.invalid_request: resolution must leave active state".to_owned());
10020        }
10021        match input.resolution {
10022            CommitOutcomeResolution::Superseded => {
10023                let replacement = input
10024                    .replacement_client_commit_id
10025                    .as_deref()
10026                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
10027                    .ok_or_else(|| {
10028                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
10029                            .to_owned()
10030                    })?;
10031                let _ = replacement;
10032            }
10033            _ if input.replacement_client_commit_id.is_some() => {
10034                return Err(
10035                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
10036                        .to_owned(),
10037                );
10038            }
10039            _ => {}
10040        }
10041        let allowed = match current.status {
10042            CommitOutcomeStatus::Conflict => matches!(
10043                input.resolution,
10044                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
10045            ),
10046            CommitOutcomeStatus::Rejected => {
10047                matches!(
10048                    input.resolution,
10049                    CommitOutcomeResolution::Superseded
10050                        | CommitOutcomeResolution::ResolvedKeepServer
10051                )
10052            }
10053            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
10054                input.resolution == CommitOutcomeResolution::Dismissed
10055            }
10056        };
10057        if !allowed {
10058            return Err(format!(
10059                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
10060                input.resolution, current.status
10061            ));
10062        }
10063
10064        self.begin_observation("syncular_outcome_resolution")?;
10065        let previous_failed = self.failed_commits.clone();
10066        let previous_conflicts = self.conflicts.clone();
10067        let previous_rejections = self.rejections.clone();
10068        let result = (|| {
10069            self.conn
10070                .execute(
10071                    "UPDATE _syncular_commit_outcomes
10072                        SET resolution = ?1, resolved_at_ms = ?2,
10073                            replacement_client_commit_id = ?3
10074                      WHERE client_commit_id = ?4 AND resolution = 'active'",
10075                    rusqlite::params![
10076                        Self::outcome_resolution_name(input.resolution),
10077                        self.clock_now_ms(),
10078                        input.replacement_client_commit_id,
10079                        input.client_commit_id
10080                    ],
10081                )
10082                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10083            let resolved = self
10084                .commit_outcome(&current.client_commit_id)?
10085                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
10086            self.conn
10087                .execute(
10088                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
10089                    rusqlite::params![input.client_commit_id],
10090                )
10091                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10092            for (commit, _) in &self.failed_commits {
10093                if commit.client_commit_id == input.client_commit_id {
10094                    for operation in &commit.ops {
10095                        self.overlay_dirty.table(&operation.table);
10096                    }
10097                }
10098            }
10099            self.failed_commits
10100                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
10101            self.prune_commit_outcomes()?;
10102            Ok(resolved)
10103        })();
10104        let resolved = match result {
10105            Ok(outcome) => outcome,
10106            Err(error) => {
10107                self.rollback_observation("syncular_outcome_resolution");
10108                self.failed_commits = previous_failed;
10109                self.conflicts = previous_conflicts;
10110                self.rejections = previous_rejections;
10111                return Err(error);
10112            }
10113        };
10114        self.conflicts
10115            .retain(|record| record.client_commit_id != current.client_commit_id);
10116        self.rejections
10117            .retain(|record| record.client_commit_id != current.client_commit_id);
10118        let mut batch = ChangeAccumulator {
10119            conflicts: current.status == CommitOutcomeStatus::Conflict,
10120            rejections: current.status == CommitOutcomeStatus::Rejected,
10121            outcomes: true,
10122            ..ChangeAccumulator::default()
10123        };
10124        if let Some(operations) = &current.operations {
10125            for operation in operations {
10126                batch.table(&operation.table);
10127            }
10128        }
10129        self.rebuild_overlay_if_dirty();
10130        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
10131            self.rollback_observation("syncular_outcome_resolution");
10132            self.failed_commits = previous_failed;
10133            self.conflicts = previous_conflicts;
10134            self.rejections = previous_rejections;
10135            return Err(error);
10136        }
10137        Ok(resolved)
10138    }
10139
10140    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
10141        self.schema_floor.as_ref()
10142    }
10143
10144    /// §7.3.5: the client's opaque auth-lease state, if any.
10145    pub fn lease_state(&self) -> Option<&LeaseState> {
10146        self.lease_state.as_ref()
10147    }
10148
10149    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
10150    /// the two lease codes set it; other errors leave leaseState untouched.
10151    fn record_lease_error(&mut self, code: &str) {
10152        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
10153            return;
10154        }
10155        let mut next = self.lease_state.clone().unwrap_or_default();
10156        next.error_code = Some(code.to_owned());
10157        self.set_lease_state(Some(next));
10158    }
10159
10160    fn set_lease_state(&mut self, next: Option<LeaseState>) {
10161        if self.lease_state == next {
10162            return;
10163        }
10164        if self.begin_observation("syncular_lease").is_err() {
10165            return;
10166        }
10167        self.lease_state = next;
10168        if let Some(lease) = &self.lease_state {
10169            if let Ok(json) = serde_json::to_string(lease) {
10170                self.set_meta(LEASE_STATE_KEY, &json);
10171            }
10172        } else {
10173            self.delete_meta(LEASE_STATE_KEY);
10174        }
10175        let batch = ChangeAccumulator {
10176            status: true,
10177            ..ChangeAccumulator::default()
10178        };
10179        if self.finish_observation("syncular_lease", batch).is_err() {
10180            self.rollback_observation("syncular_lease");
10181        }
10182    }
10183
10184    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
10185        if self.schema_floor == next {
10186            return;
10187        }
10188        if self.begin_observation("syncular_schema_floor").is_err() {
10189            return;
10190        }
10191        self.schema_floor = next;
10192        self.stopped = self.schema_floor.is_some();
10193        if let Some(floor) = &self.schema_floor {
10194            if let Ok(json) = serde_json::to_string(floor) {
10195                self.set_meta(SCHEMA_FLOOR_KEY, &json);
10196            }
10197        } else {
10198            self.delete_meta(SCHEMA_FLOOR_KEY);
10199        }
10200        let batch = ChangeAccumulator {
10201            status: true,
10202            ..ChangeAccumulator::default()
10203        };
10204        if self
10205            .finish_observation("syncular_schema_floor", batch)
10206            .is_err()
10207        {
10208            self.rollback_observation("syncular_schema_floor");
10209        }
10210    }
10211
10212    fn set_upgrading(&mut self, value: bool) {
10213        if self.upgrading == value {
10214            return;
10215        }
10216        if self.begin_observation("syncular_upgrading").is_err() {
10217            return;
10218        }
10219        self.upgrading = value;
10220        let batch = ChangeAccumulator {
10221            status: true,
10222            ..ChangeAccumulator::default()
10223        };
10224        if self
10225            .finish_observation("syncular_upgrading", batch)
10226            .is_err()
10227        {
10228            self.rollback_observation("syncular_upgrading");
10229        }
10230    }
10231
10232    pub fn sync_needed(&self) -> bool {
10233        self.sync_needed
10234    }
10235
10236    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
10237        let sub = self.subs.iter().find(|s| s.id == id)?;
10238        Some(SubscriptionStateView {
10239            id: sub.id.clone(),
10240            table: sub.table.clone(),
10241            status: sub.state.name().to_owned(),
10242            cursor: sub.cursor,
10243            has_resume_token: sub.bootstrap_state.is_some(),
10244            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
10245            reason_code: sub.reason_code.clone(),
10246        })
10247    }
10248
10249    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
10250        let schema_table = self
10251            .schema
10252            .table(table)
10253            .ok_or_else(|| format!("unknown table {table:?}"))?;
10254        let sql = format!(
10255            "SELECT * FROM {} ORDER BY {} ASC",
10256            visible_table(table),
10257            quote_ident(&schema_table.primary_key)
10258        );
10259        let mut stmt = self
10260            .conn
10261            .prepare(&sql)
10262            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10263        let mut rows = stmt
10264            .query([])
10265            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10266        let mut out = Vec::new();
10267        while let Some(row) = rows
10268            .next()
10269            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10270        {
10271            let mut values = Map::new();
10272            for (i, column) in schema_table.columns.iter().enumerate() {
10273                let value = row
10274                    .get_ref(i)
10275                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10276                values.insert(column.name.clone(), sql_ref_to_json(column, value));
10277            }
10278            let version: i64 = row
10279                .get(schema_table.columns.len())
10280                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10281            let row_id = match values.get(&schema_table.primary_key) {
10282                Some(Value::String(s)) => s.clone(),
10283                Some(Value::Number(n)) => n.to_string(),
10284                Some(Value::Bool(b)) => b.to_string(),
10285                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
10286            };
10287            out.push(RowState {
10288                row_id,
10289                version,
10290                values,
10291            });
10292        }
10293        Ok(out)
10294    }
10295
10296    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
10297    //
10298    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
10299    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
10300    // the op with `yrs`, re-encodes the whole doc state, and records a
10301    // baseVersion-less sparse upsert carrying only the primary key and the
10302    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
10303    // the overlay re-materializes the edit immediately (optimistic apply,
10304    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
10305    // Byte-compatible with `@syncular/crdt-yjs`.
10306
10307    /// The current stored value of a `crdt` column for one row — the visible
10308    /// (optimistic) bytes, or `None` when the row is absent or the column is
10309    /// NULL (the empty document, §5.10.1). Errors if the column is not a
10310    /// `crdt` column (guards the app against a typo'd column name).
10311    #[cfg(feature = "crdt-yjs")]
10312    fn crdt_column_bytes(
10313        &self,
10314        table: &str,
10315        row_id: &str,
10316        column: &str,
10317    ) -> Result<Option<Vec<u8>>, String> {
10318        let schema_table = self
10319            .schema
10320            .table(table)
10321            .ok_or_else(|| format!("unknown table {table:?}"))?;
10322        let col = schema_table
10323            .columns
10324            .iter()
10325            .find(|c| c.name == column)
10326            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
10327        if col.ty != ColumnType::Crdt {
10328            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
10329        }
10330        let sql = format!(
10331            "SELECT {} FROM {} WHERE {}",
10332            quote_ident(column),
10333            visible_table(table),
10334            row_id_predicate(schema_table)
10335        );
10336        let bytes: Option<Vec<u8>> = self
10337            .conn
10338            .prepare_cached(&sql)
10339            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10340            .query_row(rusqlite::params![row_id], |row| {
10341                row.get::<_, Option<Vec<u8>>>(0)
10342            })
10343            .map_err(|e| match e {
10344                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
10345                other => other.to_string(),
10346            })?;
10347        Ok(bytes)
10348    }
10349
10350    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
10351    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
10352    /// An absent row / NULL column is the empty document (empty string).
10353    #[cfg(feature = "crdt-yjs")]
10354    pub fn crdt_text(
10355        &self,
10356        table: &str,
10357        row_id: &str,
10358        column: &str,
10359        name: &str,
10360    ) -> Result<String, String> {
10361        let bytes = self
10362            .crdt_column_bytes(table, row_id, column)?
10363            .unwrap_or_default();
10364        crate::crdt::text(&bytes, name)
10365    }
10366
10367    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
10368    /// record the resulting crdt-only sparse operation. Returns the enqueued
10369    /// `clientCommitId`.
10370    #[cfg(feature = "crdt-yjs")]
10371    pub fn crdt_insert_text(
10372        &mut self,
10373        table: &str,
10374        row_id: &str,
10375        column: &str,
10376        name: &str,
10377        index: u32,
10378        value: &str,
10379    ) -> Result<String, String> {
10380        let current = self
10381            .crdt_column_bytes(table, row_id, column)?
10382            .unwrap_or_default();
10383        let update = crate::crdt::insert_text(&current, name, index, value)?;
10384        self.crdt_push_update(table, row_id, column, &update)
10385    }
10386
10387    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
10388    /// record the resulting crdt-only sparse operation. Returns the enqueued
10389    /// `clientCommitId`.
10390    #[cfg(feature = "crdt-yjs")]
10391    pub fn crdt_delete_text(
10392        &mut self,
10393        table: &str,
10394        row_id: &str,
10395        column: &str,
10396        name: &str,
10397        index: u32,
10398        len: u32,
10399    ) -> Result<String, String> {
10400        let current = self
10401            .crdt_column_bytes(table, row_id, column)?
10402            .unwrap_or_default();
10403        let update = crate::crdt::delete_text(&current, name, index, len)?;
10404        self.crdt_push_update(table, row_id, column, &update)
10405    }
10406
10407    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
10408    /// `crdt` column's current state and record the resulting crdt-only sparse
10409    /// operation. The app authored the update with its own `yrs` model.
10410    /// Returns the enqueued `clientCommitId`.
10411    #[cfg(feature = "crdt-yjs")]
10412    pub fn crdt_apply_update(
10413        &mut self,
10414        table: &str,
10415        row_id: &str,
10416        column: &str,
10417        update: &[u8],
10418    ) -> Result<String, String> {
10419        let current = self
10420            .crdt_column_bytes(table, row_id, column)?
10421            .unwrap_or_default();
10422        let next = crate::crdt::apply_update(&current, update)?;
10423        self.crdt_push_update(table, row_id, column, &next)
10424    }
10425
10426    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
10427    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
10428    /// enqueue it. The server merges the crdt column and leaves every other
10429    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
10430    /// clobber a concurrent edit to another column. A locally absent row
10431    /// records the same partial operation; the server answers per §6.2.
10432    #[cfg(feature = "crdt-yjs")]
10433    fn crdt_push_update(
10434        &mut self,
10435        table: &str,
10436        row_id: &str,
10437        column: &str,
10438        crdt_bytes: &[u8],
10439    ) -> Result<String, String> {
10440        let mut bytes_obj = Map::new();
10441        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
10442        let mut partial = Map::new();
10443        partial.insert(column.to_owned(), Value::Object(bytes_obj));
10444        self.patch(table, row_id, partial, None)
10445    }
10446
10447    /// Run an arbitrary read-only SQL query against the local database and
10448    /// return each row as a `column-name → JSON value` map. This is the seam
10449    /// the React `useSyncQuery` live-query API needs (it takes app-authored
10450    /// SQL over the visible tables/views, not a fixed table read like
10451    /// [`read_rows`]).
10452    ///
10453    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
10454    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
10455    /// same envelope the command surface uses everywhere else. Output BLOB
10456    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
10457    ///
10458    /// The result column typing is dynamic (SQLite's stored affinity), because
10459    /// arbitrary SQL can alias, join, and compute — there is no schema column
10460    /// to consult per output cell, unlike [`read_rows`].
10461    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
10462        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
10463    }
10464
10465    /// Repository benchmark and conformance access, present only with `bench-internals`.
10466    #[cfg(feature = "bench-internals")]
10467    #[doc(hidden)]
10468    pub fn benchmark_connection(&mut self) -> &mut Connection {
10469        &mut self.conn
10470    }
10471
10472    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
10473    #[cfg(feature = "bench-internals")]
10474    #[doc(hidden)]
10475    pub fn benchmark_phases(
10476        &self,
10477        enabled: Option<bool>,
10478        reset: bool,
10479    ) -> Result<Option<Value>, String> {
10480        self.benchmark_phases.configure(enabled, reset)?;
10481        Ok(self.benchmark_phases.snapshot())
10482    }
10483
10484    /// One serialized, read-only authority observation, including durable coverage.
10485    pub fn authority_snapshot(&self) -> Result<Value, String> {
10486        validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
10487        if self.limits.authority_reads.is_empty() {
10488            return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
10489        }
10490        self.conn
10491            .execute_batch("SAVEPOINT authority_snapshot")
10492            .map_err(|e| e.to_string())?;
10493        let result = (|| {
10494            let revision = self
10495                .get_meta(LOCAL_REVISION_KEY)
10496                .ok_or("sync.local_corrupt: authority revision is missing")?;
10497            let parsed_revision = revision
10498                .parse::<u64>()
10499                .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
10500            if parsed_revision.to_string() != revision {
10501                return Err("sync.local_corrupt: authority revision is invalid".into());
10502            }
10503            let mut tables = Vec::new();
10504            for read in &self.limits.authority_reads {
10505                let table = self
10506                    .schema
10507                    .table(&read.table)
10508                    .ok_or("client.authority_read_forbidden: authority table is not declared")?;
10509                let scalar = |column: &str, value: &Value| -> Result<String, String> {
10510                    let ty = table
10511                        .columns
10512                        .iter()
10513                        .find(|c| c.name == column)
10514                        .expect("validated authority column")
10515                        .ty;
10516                    if ty == ColumnType::Boolean {
10517                        if let Some(value) = value.as_bool() {
10518                            return Ok(value.to_string());
10519                        }
10520                        return match value.as_i64() {
10521                            Some(0) => Ok("false".into()),
10522                            Some(1) => Ok("true".into()),
10523                            _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
10524                        };
10525                    }
10526                    match value {
10527                        Value::String(s) => Ok(s.clone()),
10528                        Value::Number(n) => Ok(n.to_string()),
10529                        Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
10530                            .get("$bigint")
10531                            .and_then(Value::as_str)
10532                            .map(str::to_owned)
10533                            .ok_or("sync.local_corrupt: authority integer is invalid".into()),
10534                        _ => {
10535                            Err("sync.local_corrupt: authority scalar is missing or invalid".into())
10536                        }
10537                    }
10538                };
10539                let matches = |row: &QueryRow| -> Result<bool, String> {
10540                    for (variable, values) in &read.scopes {
10541                        let column = table
10542                            .scope_column(variable)
10543                            .expect("validated authority scope");
10544                        let value = row
10545                            .get(column)
10546                            .ok_or("sync.local_corrupt: authority scope column is missing")?;
10547                        if value.is_null() || !values.contains(&scalar(column, value)?) {
10548                            return Ok(false);
10549                        }
10550                    }
10551                    Ok(true)
10552                };
10553                let selected = read
10554                    .columns
10555                    .iter()
10556                    .map(|c| quote_ident(c))
10557                    .collect::<Vec<_>>()
10558                    .join(",");
10559                let mut intent = BTreeSet::new();
10560                let mut relevant = BTreeSet::new();
10561                for row in query_connection(&self.conn,
10562                    "SELECT id,values_json FROM (SELECT json_extract(value,'$.table') AS tbl,json_extract(value,'$.rowId') AS id,json_extract(value,'$.values') AS values_json FROM _syncular_outbox,json_each(ops_json) UNION ALL SELECT json_extract(value,'$.table'),json_extract(value,'$.rowId'),json_extract(value,'$.values') FROM _syncular_failed_commits,json_each(operations_json) UNION ALL SELECT tbl,id,intent_json FROM _syncular_acked_rows) WHERE tbl=?",
10563                    &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
10564                    let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
10565                    intent.insert(id.clone());
10566                    if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
10567                        let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
10568                        if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
10569                    }
10570                }
10571                let mut rows = BTreeMap::new();
10572                for full in [base_table(&read.table), visible_table(&read.table)] {
10573                    for mut values in query_connection(
10574                        &self.conn,
10575                        &format!(
10576                            "SELECT {selected},_syncular_version AS authority_version FROM {full}"
10577                        ),
10578                        &[],
10579                    )
10580                    .map_err(|e| e.to_string())?
10581                    {
10582                        if !matches(&values)? {
10583                            continue;
10584                        }
10585                        let id = scalar(
10586                            &table.primary_key,
10587                            values
10588                                .get(&table.primary_key)
10589                                .expect("selected primary key"),
10590                        )?;
10591                        relevant.insert(id.clone());
10592                        if full == base_table(&read.table) {
10593                            let version = values
10594                                .remove("authority_version")
10595                                .expect("selected version");
10596                            let version = version
10597                                .as_i64()
10598                                .ok_or("sync.local_corrupt: authority server version is invalid")?;
10599                            if version >= 0 {
10600                                rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
10601                            }
10602                        }
10603                    }
10604                }
10605                intent.retain(|id| relevant.contains(id));
10606                let rows = rows.into_values().collect::<Vec<_>>();
10607                let mut persisted = Vec::new();
10608                for row in query_connection(
10609                    &self.conn,
10610                    "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
10611                    &[Value::String(read.table.clone())],
10612                )
10613                .map_err(|e| e.message)?
10614                {
10615                    let state: Value = serde_json::from_str(
10616                        row.get("state_json")
10617                            .and_then(Value::as_str)
10618                            .ok_or("sync.local_corrupt: authority subscription state is missing")?,
10619                    )
10620                    .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
10621                    let effective = state.get("effectiveScopes").and_then(Value::as_object);
10622                    let status = state
10623                        .get("status")
10624                        .and_then(Value::as_str)
10625                        .ok_or("sync.local_corrupt: authority subscription status is missing")?;
10626                    let cursor = state
10627                        .get("cursor")
10628                        .and_then(Value::as_i64)
10629                        .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
10630                    let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
10631                        && status == "active"
10632                        && cursor >= 0
10633                        && state.get("bootstrapState").is_none_or(Value::is_null)
10634                        && state.get("params").is_none_or(Value::is_null);
10635                    let exposed = effective.map(|s| {
10636                        read.scopes
10637                            .keys()
10638                            .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
10639                            .collect::<Map<_, _>>()
10640                    });
10641                    let requested = read
10642                        .scopes
10643                        .keys()
10644                        .map(|v| {
10645                            (
10646                                v.clone(),
10647                                state
10648                                    .get("requested")
10649                                    .and_then(|s| s.get(v))
10650                                    .cloned()
10651                                    .unwrap_or(json!([])),
10652                            )
10653                        })
10654                        .collect::<Map<_, _>>();
10655                    persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
10656                }
10657                fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
10658                    match selectors.split_first() {
10659                        None => !candidates.is_empty(),
10660                        Some(((variable, values), rest)) => values.iter().all(|value| {
10661                            covered(
10662                                rest,
10663                                &candidates
10664                                    .iter()
10665                                    .copied()
10666                                    .filter(|p| {
10667                                        p["effectiveScopes"][variable.as_str()]
10668                                            .as_array()
10669                                            .is_some_and(|held| {
10670                                                held.contains(&Value::String(value.clone()))
10671                                                    || held.contains(&Value::String("*".into()))
10672                                            })
10673                                    })
10674                                    .collect::<Vec<_>>(),
10675                            )
10676                        }),
10677                    }
10678                }
10679                let coverage = if covered(
10680                    &read.scopes.iter().collect::<Vec<_>>(),
10681                    &persisted
10682                        .iter()
10683                        .filter(|p| p["complete"] == true)
10684                        .collect::<Vec<_>>(),
10685                ) {
10686                    "complete"
10687                } else if persisted.iter().any(|p| {
10688                    p["status"] == "active"
10689                        && read.scopes.iter().all(|(v, values)| {
10690                            values.iter().any(|value| {
10691                                p["requestedScopes"][v].as_array().is_some_and(|held| {
10692                                    held.contains(&Value::String(value.clone()))
10693                                        || held.contains(&Value::String("*".into()))
10694                                })
10695                            })
10696                        })
10697                }) {
10698                    "pending"
10699                } else {
10700                    "missing"
10701                };
10702                tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
10703            }
10704            Ok(
10705                json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
10706            )
10707        })();
10708        match result {
10709            Ok(snapshot) => {
10710                self.conn
10711                    .execute_batch("RELEASE authority_snapshot")
10712                    .map_err(|e| e.to_string())?;
10713                Ok(snapshot)
10714            }
10715            Err(error) => {
10716                self.conn
10717                    .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
10718                    .map_err(|e| format!("{error}; rollback failed: {e}"))?;
10719                Err(error)
10720            }
10721        }
10722    }
10723
10724    /// Rows, coverage, and local revision from one SQLite read snapshot.
10725    /// §7.5 atomic snapshot read. An `owner` records a failure in
10726    /// `diagnostics_snapshot().query_failures` until its next successful read.
10727    pub fn query_snapshot(
10728        &mut self,
10729        sql: &str,
10730        params: &[QueryValue],
10731        coverage: &[WindowCoverage],
10732        owner: Option<QueryOwner<'_>>,
10733    ) -> Result<QuerySnapshot, String> {
10734        let result = snapshot_connection(&self.conn, sql, params, coverage);
10735        if let Some(owner) = owner {
10736            self.record_query_read(owner, result.as_ref().err());
10737        }
10738        result.map_err(|failure| failure.to_string())
10739    }
10740
10741    /// §7.6: record the outcome of one owned snapshot read, including reads a
10742    /// host ran on a separate connection. A success removes the owner's entry;
10743    /// an identical failure leaves it unchanged; a changed failure moves last.
10744    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
10745        let position = self
10746            .query_failures
10747            .iter()
10748            .position(|entry| entry.id == owner.id);
10749        let Some(failure) = failure else {
10750            if let Some(index) = position {
10751                self.query_failures.remove(index);
10752            }
10753            return;
10754        };
10755        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
10756            .into_iter()
10757            .map(str::to_owned)
10758            .collect();
10759        let code = failure.code.unwrap_or("client.query_failed").to_owned();
10760        if position.is_some_and(|index| {
10761            let entry = &self.query_failures[index];
10762            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
10763        }) {
10764            return;
10765        }
10766        if let Some(index) = position {
10767            self.query_failures.remove(index);
10768        }
10769        self.query_failures.push(DiagnosticQueryFailure {
10770            id: owner.id.to_owned(),
10771            tables,
10772            code,
10773            sqlite_code: failure.sqlite_code,
10774            at_ms: self.clock_now_ms(),
10775        });
10776        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
10777            self.query_failures.remove(0);
10778        }
10779    }
10780
10781    // -- request building ---------------------------------------------------------
10782
10783    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
10784        #[cfg(feature = "bench-internals")]
10785        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
10786        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
10787        let mut frames = vec![Frame::ReqHeader {
10788            client_id: self.client_id.clone(),
10789            schema_version: self.schema.version,
10790            log_epoch: log_epoch.clone(),
10791        }];
10792        let mut pushed_ids = Vec::new();
10793        let mut ops_in_request = 0usize;
10794        let mut deferred_commits = 0usize;
10795        #[cfg(feature = "bench-internals")]
10796        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
10797        for (index, commit) in self
10798            .outbox
10799            .iter()
10800            .take(if log_epoch.is_some() {
10801                self.outbox.len()
10802            } else {
10803                0
10804            })
10805            .enumerate()
10806        {
10807            // §6.1 splitBatch: stop at the operation cap — commits apply in
10808            // order, so everything from the first non-fitting commit on is
10809            // deferred to the next round. A single over-cap commit still goes
10810            // alone (commits are atomic and cannot be split).
10811            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
10812                deferred_commits = self.outbox.len() - index;
10813                break;
10814            }
10815            ops_in_request += commit.ops.len();
10816            let operations = commit
10817                .ops
10818                .iter()
10819                .map(|op| {
10820                    let payload = op.values.as_ref().and_then(|values| {
10821                        let table = self.schema.table(&op.table)?;
10822                        // §0: outbox entries encode at send time with the
10823                        // current codec (validated at mutate()). §6.1: the
10824                        // values map's keys are the sparse presence set.
10825                        // §5.11: encrypted columns are encrypted here, with
10826                        // an absent key-id selector read from the stored row.
10827                        // `sync_inner` runs `drop_unencodable_outbox` first,
10828                        // so an encrypt failure cannot reach this encode; the
10829                        // author seam validated every other failure.
10830                        let fallback = self.stored_key_fallback(table, &op.row_id);
10831                        encode_sparse_row_json(
10832                            table,
10833                            &op.row_id,
10834                            values,
10835                            &self.encryption,
10836                            fallback.as_ref(),
10837                        )
10838                        .ok()
10839                    });
10840                    ssp2::model::Operation {
10841                        table: op.table.clone(),
10842                        row_id: op.row_id.clone(),
10843                        op: if op.upsert { Op::Upsert } else { Op::Delete },
10844                        base_version: op.base_version,
10845                        payload,
10846                    }
10847                })
10848                .collect();
10849            frames.push(Frame::PushCommit {
10850                client_commit_id: commit.client_commit_id.clone(),
10851                operations,
10852            });
10853            pushed_ids.push(commit.client_commit_id.clone());
10854        }
10855        #[cfg(feature = "bench-internals")]
10856        drop(outbox_phase);
10857        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
10858        // bare URL — capability negotiation, decided per transport.
10859        let accept = self.limits.accept.unwrap_or(if url_capable {
10860            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
10861        } else {
10862            DEFAULT_ACCEPT
10863        });
10864        frames.push(Frame::PullHeader {
10865            limit_commits: self.limits.limit_commits.unwrap_or(0),
10866            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
10867            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
10868            accept,
10869        });
10870        let mut fresh = Vec::new();
10871        for sub in &self.subs {
10872            if sub.state != SubState::Active {
10873                continue;
10874            }
10875            let mut scopes = sub.requested.clone();
10876            sort_scope_map(&mut scopes);
10877            frames.push(Frame::Subscription {
10878                id: sub.id.clone(),
10879                table: sub.table.clone(),
10880                scopes,
10881                params: sub.params.clone().map(RawJson),
10882                cursor: sub.cursor,
10883                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
10884            });
10885            fresh.push((
10886                sub.id.clone(),
10887                sub.cursor < 0 && sub.bootstrap_state.is_none(),
10888            ));
10889        }
10890        let message = Message {
10891            wire_version: WIRE_VERSION,
10892            msg_kind: MsgKind::Request,
10893            frames,
10894        };
10895        (
10896            message,
10897            RequestMeta {
10898                pushed_ids,
10899                fresh,
10900                accept,
10901                deferred_commits,
10902            },
10903        )
10904    }
10905
10906    // -- sync -------------------------------------------------------------------
10907
10908    /// Cloneable observation handle; no client lock is needed to read it.
10909    pub fn progress(&self) -> ProgressObserver {
10910        self.progress.clone()
10911    }
10912
10913    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
10914        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
10915            Ok(prepared) => prepared,
10916            Err(outcome) => return *outcome,
10917        };
10918        let mut next = prepared;
10919        loop {
10920            match self.apply_sync_round(next.exchange(transport)) {
10921                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10922                crate::AppliedSyncRound::Complete {
10923                    outcome, controls, ..
10924                } => {
10925                    if self.transport_enabled {
10926                        for text in controls {
10927                            let _ = transport.realtime_send(&text);
10928                        }
10929                    } else {
10930                        self.disconnect_realtime(transport);
10931                    }
10932                    return outcome;
10933                }
10934            }
10935        }
10936    }
10937
10938    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
10939        if let SyncOutcome::Failed {
10940            error_code,
10941            message,
10942            details,
10943        } = &mut outcome
10944        {
10945            if let Some(failure) = self.storage_failure.borrow().as_ref() {
10946                let code = failure
10947                    .code
10948                    .expect("retained storage failures are classified");
10949                *error_code = code.to_owned();
10950                *message = match code {
10951                    "client.storage_full" => "local SQLite storage is full",
10952                    "client.storage_io" => "local SQLite storage I/O failed",
10953                    "client.storage_corrupt" => "local SQLite storage is corrupt",
10954                    _ => unreachable!("unrecognized classified storage failure"),
10955                }
10956                .to_owned();
10957                *details = failure.details();
10958            }
10959        }
10960        let retry_delay_ms = self.round_retry_delay_ms;
10961        self.progress.update(|p| match &outcome {
10962            SyncOutcome::Ok(report) if report.failed.is_empty() => {
10963                p.state = ProgressState::Complete
10964            }
10965            SyncOutcome::Ok(_) => {
10966                p.state = ProgressState::Failed;
10967                p.error_code = Some("sync.scope_revoked".into());
10968            }
10969            SyncOutcome::Failed { error_code, .. } => {
10970                p.state = ProgressState::Failed;
10971                p.error_code = Some(Self::diagnostic_code(error_code));
10972                p.retry_delay_ms = retry_delay_ms;
10973            }
10974            SyncOutcome::RealtimeUnavailable { .. } => {
10975                p.state = ProgressState::Failed;
10976                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
10977                p.retry_delay_ms = retry_delay_ms;
10978            }
10979        });
10980        let completed_at_ms = self.clock_now_ms();
10981        self.last_round = Some(match &outcome {
10982            SyncOutcome::Ok(report) => DiagnosticLastRound {
10983                status: "succeeded".to_owned(),
10984                started_at_ms,
10985                completed_at_ms,
10986                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10987                counters: Some(DiagnosticRoundCounters {
10988                    pushed: report.pushed,
10989                    applied: report.applied.len(),
10990                    rejected: report.rejected.len(),
10991                    retryable: report.retryable.len(),
10992                    conflicts: report.conflicts,
10993                    commits_applied: report.commits_applied,
10994                    segment_rows_applied: report.segment_rows_applied,
10995                    bootstrapping: report.bootstrapping.len(),
10996                    resets: report.resets.len(),
10997                    revoked: report.revoked.len(),
10998                    failed: report.failed.len(),
10999                    deferred_commits: report.deferred_commits,
11000                }),
11001                error_code: None,
11002            },
11003            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
11004                status: "failed".to_owned(),
11005                started_at_ms,
11006                completed_at_ms,
11007                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
11008                counters: None,
11009                error_code: Some(Self::diagnostic_code(error_code)),
11010            },
11011            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
11012                status: "failed".to_owned(),
11013                started_at_ms,
11014                completed_at_ms,
11015                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
11016                counters: None,
11017                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
11018            },
11019        });
11020        outcome
11021    }
11022
11023    /// Capture one request on the client owner. Mutations made after this
11024    /// call remain in the outbox for a later round; no network I/O occurs here.
11025    pub fn prepare_sync_round(
11026        &mut self,
11027        url_capable: bool,
11028    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
11029        if !self.transport_enabled {
11030            return Err(Box::new(SyncOutcome::Failed {
11031                error_code: "sync.offline".into(),
11032                message: "transport gate is closed".into(),
11033                details: None,
11034            }));
11035        }
11036        if self.active_round.is_some() {
11037            return Err(Box::new(SyncOutcome::Failed {
11038                error_code: "client.round_in_progress".into(),
11039                message: "a sync round is already in flight".into(),
11040                details: None,
11041            }));
11042        }
11043        self.storage_failure.borrow_mut().take();
11044        self.progress.start();
11045        self.round_retry_delay_ms = None;
11046        let started_at_ms = self.clock_now_ms();
11047        let prepared = (|| {
11048            if self.stopped {
11049                return Err(Box::new(SyncOutcome::Ok(SyncReport {
11050                    schema_floor: self.schema_floor.clone(),
11051                    ..SyncReport::default()
11052                })));
11053            }
11054            if self.security_preflight {
11055                return Err(Box::new(SyncOutcome::Failed {
11056                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
11057                    message: "security preflight must complete before syncing".into(),
11058                    details: None,
11059                }));
11060            }
11061            self.set_sync_needed(false, false);
11062            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
11063                self.pending_blob_uploads().map_err(|e| {
11064                    Box::new(SyncOutcome::Failed {
11065                        error_code: e.code,
11066                        message: e.message,
11067                        details: e.details,
11068                    })
11069                })?
11070            } else {
11071                Vec::new()
11072            };
11073            self.drain_pending_evictions()
11074                .and_then(|_| self.drop_unencodable_outbox())
11075                .map_err(|message| {
11076                    Box::new(SyncOutcome::Failed {
11077                        error_code: "storage.failed".into(),
11078                        message,
11079                        details: None,
11080                    })
11081                })?;
11082            if self.realtime_state != RealtimeState::Connected
11083                && self.realtime_policy == RealtimePolicy::Required
11084            {
11085                let retry_delay_ms = self.schedule_background_retry();
11086                self.realtime_retry_delay_ms = Some(retry_delay_ms);
11087                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
11088                    state: self.realtime_state,
11089                    reason_code: self.realtime_reason_code.clone(),
11090                    retry_delay_ms,
11091                }));
11092            }
11093            let (message, meta) = self.build_request(url_capable);
11094            let id = uuid::Uuid::new_v4();
11095            self.active_round = Some(id);
11096            Ok(crate::PreparedSyncRound {
11097                id,
11098                started_at_ms,
11099                message,
11100                meta,
11101                uploads: uploads.into(),
11102                realtime: self.realtime_state == RealtimeState::Connected,
11103                scopes: self
11104                    .subs
11105                    .iter()
11106                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
11107                    .collect(),
11108                #[cfg(feature = "bench-internals")]
11109                benchmark_phases: self.benchmark_phases.clone(),
11110                progress: self.progress.clone(),
11111                fixed_now: self.now_ms,
11112            })
11113        })();
11114        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
11115    }
11116
11117    /// Discard the reply of an old authorization/subscription context. Its
11118    /// submitted commits stay durable and can be retried with the same ids.
11119    pub fn cancel_sync_round(&mut self) {
11120        self.active_round = None;
11121    }
11122
11123    /// Apply captured network results on the owner, preserving all normal
11124    /// version, schema, content-address and cursor checks. No network I/O.
11125    pub fn apply_sync_round(
11126        &mut self,
11127        completed: crate::CompletedSyncRound,
11128    ) -> crate::AppliedSyncRound {
11129        let prepared = completed.prepared;
11130        if self.active_round != Some(prepared.id) || self.security_preflight {
11131            return crate::AppliedSyncRound::Complete {
11132                outcome: SyncOutcome::Failed {
11133                    error_code: "client.round_cancelled".into(),
11134                    message: "sync round belongs to a released client context".into(),
11135                    details: None,
11136                },
11137                controls: Vec::new(),
11138                more: false,
11139                bootstrap_advanced: false,
11140            };
11141        }
11142        self.storage_failure.borrow_mut().take();
11143        let (response, mut downloads, transport_failed) = match completed.exchange {
11144            crate::round::ExchangeResult::Upload { id, result } => {
11145                let result = result.and_then(|()| {
11146                    self.conn
11147                        .execute(
11148                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
11149                            rusqlite::params![id],
11150                        )
11151                        .map(|_| ())
11152                        .map_err(|error| {
11153                            TransportError::new(
11154                                "client.failed",
11155                                Self::sqlite_failure(&self.storage_failure, error),
11156                            )
11157                        })
11158                });
11159                match result {
11160                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
11161                    Err(TransportError {
11162                        code,
11163                        message,
11164                        details,
11165                    }) => {
11166                        self.active_round = None;
11167                        if Self::retryable_failure_code(&code) {
11168                            self.schedule_background_retry();
11169                        }
11170                        let outcome = self.finish_sync_round(
11171                            prepared.started_at_ms,
11172                            SyncOutcome::Failed {
11173                                error_code: code,
11174                                message,
11175                                details,
11176                            },
11177                        );
11178                        return crate::AppliedSyncRound::Complete {
11179                            outcome,
11180                            controls: Vec::new(),
11181                            more: false,
11182                            bootstrap_advanced: false,
11183                        };
11184                    }
11185                }
11186            }
11187            crate::round::ExchangeResult::Reply {
11188                response,
11189                downloads,
11190                transport_failed,
11191            } => (response, downloads, transport_failed),
11192        };
11193        self.active_round = None;
11194        let before = self
11195            .subs
11196            .iter()
11197            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
11198            .collect::<Vec<_>>();
11199        let outcome = (|| {
11200            let response = match response {
11201                Ok(response) => response,
11202                Err(TransportError {
11203                    code,
11204                    message,
11205                    details,
11206                }) => {
11207                    if transport_failed {
11208                        if prepared.realtime {
11209                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
11210                        }
11211                        self.record_lease_error(&code);
11212                    }
11213                    if Self::retryable_failure_code(&code) {
11214                        let delay = self.schedule_background_retry();
11215                        if prepared.realtime && transport_failed {
11216                            self.realtime_retry_delay_ms = Some(delay);
11217                        }
11218                    }
11219                    return SyncOutcome::Failed {
11220                        error_code: code,
11221                        message,
11222                        details,
11223                    };
11224                }
11225            };
11226            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
11227            if let SyncOutcome::Ok(report) = &mut outcome {
11228                report.deferred_commits = prepared.meta.deferred_commits;
11229            }
11230            match &outcome {
11231                SyncOutcome::Ok(_) => self.reset_background_retry(),
11232                SyncOutcome::Failed { error_code, .. }
11233                    if Self::retryable_failure_code(error_code) =>
11234                {
11235                    self.schedule_background_retry();
11236                }
11237                _ => {}
11238            }
11239            if prepared.meta.deferred_commits > 0 {
11240                self.set_sync_needed(true, true);
11241            }
11242            self.previous_version_lifetime_check();
11243            outcome
11244        })();
11245        let more = self.transport_enabled
11246            && matches!(&outcome, SyncOutcome::Ok(report) if !report.bootstrapping.is_empty() || report.commits_applied > 0 || report.segment_rows_applied > 0 || !report.resets.is_empty() || self.sync_needed);
11247        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
11248            && before
11249                != self
11250                    .subs
11251                    .iter()
11252                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
11253                    .collect::<Vec<_>>();
11254        crate::AppliedSyncRound::Complete {
11255            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
11256            controls: if self.transport_enabled {
11257                downloads.controls
11258            } else {
11259                Vec::new()
11260            },
11261            more,
11262            bootstrap_advanced,
11263        }
11264    }
11265
11266    pub fn sync_until_idle(
11267        &mut self,
11268        transport: &mut dyn Transport,
11269        max_rounds: Option<u32>,
11270    ) -> SyncOutcome {
11271        let rounds = max_rounds.unwrap_or(20).max(1);
11272        let mut aggregate = SyncReport::default();
11273        let mut spent = 0;
11274        while spent < rounds {
11275            spent += 1;
11276            let before = self
11277                .subs
11278                .iter()
11279                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
11280                .collect::<Vec<_>>();
11281            let outcome = self.sync(transport);
11282            match outcome {
11283                SyncOutcome::Failed {
11284                    error_code,
11285                    message,
11286                    details,
11287                } => {
11288                    return SyncOutcome::Failed {
11289                        details,
11290                        error_code,
11291                        message,
11292                    };
11293                }
11294                SyncOutcome::RealtimeUnavailable { .. } => return outcome,
11295                SyncOutcome::Ok(report) => {
11296                    if max_rounds.is_none()
11297                        && report.segment_rows_applied > 0
11298                        && !report.bootstrapping.is_empty()
11299                        && before
11300                            != self
11301                                .subs
11302                                .iter()
11303                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
11304                                .collect::<Vec<_>>()
11305                    {
11306                        spent = 0;
11307                    }
11308                    aggregate.merge(&report);
11309                    // §4.5: pull again whenever the response contained
11310                    // commits or segments; resets re-bootstrap; a pending
11311                    // resume token continues paging (§4.7); a raised
11312                    // sync-needed signal covers §6.1 splitBatch remainders
11313                    // (deferred outbox commits push on the next round).
11314                    let more = !report.bootstrapping.is_empty()
11315                        || report.commits_applied > 0
11316                        || report.segment_rows_applied > 0
11317                        || !report.resets.is_empty()
11318                        || self.sync_needed;
11319                    if !more {
11320                        return SyncOutcome::Ok(aggregate);
11321                    }
11322                }
11323            }
11324        }
11325        SyncOutcome::Failed {
11326            details: None,
11327            error_code: "sync.invalid_request".into(),
11328            message: "sync did not reach idle within the round budget".into(),
11329        }
11330    }
11331
11332    fn process_response(
11333        &mut self,
11334        transport: &mut dyn Transport,
11335        response: Message,
11336        meta: &RequestMeta,
11337    ) -> SyncOutcome {
11338        #[cfg(feature = "bench-internals")]
11339        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
11340        let mut report = SyncReport {
11341            pushed: meta.pushed_ids.len() as u32,
11342            ..SyncReport::default()
11343        };
11344        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
11345            HashMap::new();
11346        let pushed_ids = meta
11347            .pushed_ids
11348            .iter()
11349            .map(String::as_str)
11350            .collect::<HashSet<_>>();
11351        let mut last_final_push_result_id: Option<String> = None;
11352        for frame in &response.frames {
11353            match frame {
11354                Frame::PushResultDetails {
11355                    client_commit_id,
11356                    entries,
11357                } => {
11358                    let details = rejection_details_by_commit
11359                        .entry(client_commit_id.clone())
11360                        .or_default();
11361                    for entry in entries {
11362                        let parsed = match RejectionDetails::parse(&entry.details.0) {
11363                            Ok(value) => value,
11364                            Err(message) => {
11365                                return SyncOutcome::Failed {
11366                                    details: None,
11367                                    error_code: "sync.invalid_request".to_owned(),
11368                                    message,
11369                                };
11370                            }
11371                        };
11372                        details.insert(entry.op_index, parsed);
11373                    }
11374                }
11375                Frame::PushResult {
11376                    client_commit_id,
11377                    status,
11378                    results,
11379                    ..
11380                } if pushed_ids.contains(client_commit_id.as_str())
11381                    && Self::push_result_is_final(*status, results) =>
11382                {
11383                    last_final_push_result_id = Some(client_commit_id.clone());
11384                }
11385                _ => {}
11386            }
11387        }
11388        let mut frames = response.frames.into_iter().peekable();
11389        if response.wire_version < 2 {
11390            return SyncOutcome::Failed {
11391                details: None,
11392                error_code: "client.invalid_host_response".to_owned(),
11393                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
11394            };
11395        }
11396        match frames.next() {
11397            Some(Frame::RespHeader {
11398                required_schema_version,
11399                latest_schema_version,
11400                log_epoch,
11401                reset_required,
11402            }) => {
11403                let Some(log_epoch) = log_epoch else {
11404                    return SyncOutcome::Failed {
11405                        details: None,
11406                        error_code: "client.invalid_host_response".to_owned(),
11407                        message: "response header omits logEpoch".to_owned(),
11408                    };
11409                };
11410                let Some(reset_required) = reset_required else {
11411                    return SyncOutcome::Failed {
11412                        details: None,
11413                        error_code: "client.invalid_host_response".to_owned(),
11414                        message: "response header omits resetRequired".to_owned(),
11415                    };
11416                };
11417                if let Some(required) = required_schema_version {
11418                    // §1.6 schema-floor response: nothing else is processed;
11419                    // stop syncing and surface the upgrade requirement.
11420                    let floor = SchemaFloor {
11421                        required_schema_version: Some(required),
11422                        latest_schema_version,
11423                    };
11424                    self.set_schema_floor(Some(floor.clone()));
11425                    report.schema_floor = Some(floor);
11426                    return SyncOutcome::Ok(report);
11427                }
11428                if reset_required {
11429                    if frames.next().is_some() {
11430                        return SyncOutcome::Failed {
11431                            details: None,
11432                            error_code: "client.invalid_host_response".to_owned(),
11433                            message: "log-epoch reset response contains body frames".to_owned(),
11434                        };
11435                    }
11436                    match self.run_log_epoch_reset(&log_epoch) {
11437                        Ok(resets) => {
11438                            report.resets = resets;
11439                            return SyncOutcome::Ok(report);
11440                        }
11441                        Err(message) => {
11442                            return SyncOutcome::Failed {
11443                                details: None,
11444                                error_code: "sync.local_corrupt".to_owned(),
11445                                message,
11446                            };
11447                        }
11448                    }
11449                }
11450                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
11451                    return SyncOutcome::Failed {
11452                        details: None,
11453                        error_code: "client.invalid_host_response".to_owned(),
11454                        message: "server changed logEpoch without requiring a reset".to_owned(),
11455                    };
11456                }
11457            }
11458            _ => {
11459                return SyncOutcome::Failed {
11460                    details: None,
11461                    error_code: "sync.invalid_request".to_owned(),
11462                    message: "response does not start with RESP_HEADER".to_owned(),
11463                };
11464            }
11465        }
11466
11467        let mut failure: Option<(String, String)> = None;
11468        while let Some(frame) = frames.next() {
11469            match frame {
11470                frame @ Frame::PushResult { .. } => {
11471                    let successful = matches!(
11472                        &frame,
11473                        Frame::PushResult {
11474                            status: PushStatus::Applied | PushStatus::Cached,
11475                            ..
11476                        }
11477                    );
11478                    let mut results = vec![frame];
11479                    if successful {
11480                        while matches!(
11481                            frames.peek(),
11482                            Some(Frame::PushResult {
11483                                status: PushStatus::Applied | PushStatus::Cached,
11484                                ..
11485                            })
11486                        ) {
11487                            if let Some(next) = frames.next() {
11488                                results.push(next);
11489                            }
11490                        }
11491                    }
11492                    if self
11493                        .handle_push_results(
11494                            &results,
11495                            &pushed_ids,
11496                            &rejection_details_by_commit,
11497                            &mut report,
11498                            last_final_push_result_id.as_deref(),
11499                        )
11500                        .is_err()
11501                    {
11502                        failure = Some((
11503                            "client.outcome_persistence_failed".to_owned(),
11504                            "local commit outcome could not be persisted".to_owned(),
11505                        ));
11506                        break;
11507                    }
11508                }
11509                Frame::PushResultDetails { .. } => {}
11510                Frame::SubStart {
11511                    id,
11512                    status,
11513                    reason_code,
11514                    effective_scopes,
11515                    bootstrap: _,
11516                } => {
11517                    let mut body = Vec::new();
11518                    let mut sub_end: Option<(i64, Option<String>)> = None;
11519                    for inner in frames.by_ref() {
11520                        match inner {
11521                            Frame::SubEnd {
11522                                next_cursor,
11523                                bootstrap_state,
11524                            } => {
11525                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
11526                                break;
11527                            }
11528                            Frame::Unknown { .. } => {}
11529                            other => body.push(other),
11530                        }
11531                    }
11532                    if let Err(SectionError::Abort(code, message)) = self.process_section(
11533                        transport,
11534                        &id,
11535                        status,
11536                        &reason_code,
11537                        effective_scopes,
11538                        body,
11539                        sub_end,
11540                        meta,
11541                        &mut report,
11542                    ) {
11543                        failure = Some((code, message));
11544                        break;
11545                    }
11546                }
11547                Frame::Lease {
11548                    lease_id,
11549                    expires_at_ms,
11550                } => {
11551                    // §7.3.5: persist the opaque lease; a fresh lease clears
11552                    // any prior lease error (the outage/revocation is over).
11553                    self.set_lease_state(Some(LeaseState {
11554                        lease_id: Some(lease_id),
11555                        expires_at_ms: Some(expires_at_ms),
11556                        error_code: None,
11557                    }));
11558                }
11559                Frame::Error { code, message, .. } => {
11560                    // §1.6: the whole request failed; already-completed
11561                    // subscriptions keep their applied data and cursors.
11562                    failure = Some((code, message));
11563                    break;
11564                }
11565                Frame::Unknown { .. } => {}
11566                _ => {
11567                    failure = Some((
11568                        "sync.invalid_request".to_owned(),
11569                        "unexpected frame in response".to_owned(),
11570                    ));
11571                    break;
11572                }
11573            }
11574        }
11575
11576        // §7.1: reconcile the visible overlay once at the response boundary
11577        // after all outbox acknowledgements and server data applied so a
11578        // batch of PUSH_RESULT frames cannot trigger repeated full-table
11579        // rebuilds. This also covers a round that aborted mid-way.
11580        if self.overlay_dirty.get() {
11581            self.rebuild_overlay();
11582        }
11583        if let Some((error_code, message)) = failure {
11584            return SyncOutcome::Failed {
11585                details: None,
11586                error_code,
11587                message,
11588            };
11589        }
11590        // §4.8 E1: the push half may have drained commits that pinned rows of
11591        // a shrunk window unit — retry any deferred evictions now.
11592        if let Err(message) = self.drain_pending_evictions() {
11593            return SyncOutcome::Failed {
11594                details: None,
11595                error_code: "storage.failed".into(),
11596                message,
11597            };
11598        }
11599        self.ack_after_pull(transport);
11600        // §7.4.5: the reset is over once the first post-reset pull round
11601        // leaves no subscription mid-bootstrap — the tables are rebuilt.
11602        if self.upgrading && report.bootstrapping.is_empty() {
11603            self.set_upgrading(false);
11604        }
11605        SyncOutcome::Ok(report)
11606    }
11607
11608    // -- push results (§6.3, §7.2) ------------------------------------------------
11609
11610    fn handle_push_results(
11611        &mut self,
11612        frames: &[Frame],
11613        pushed_ids: &HashSet<&str>,
11614        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
11615        report: &mut SyncReport,
11616        last_final_push_result_id: Option<&str>,
11617    ) -> Result<(), String> {
11618        self.begin_observation("syncular_push_results")?;
11619        let previous_failed = self.failed_commits.clone();
11620        let previous_sync_needed = self.sync_needed;
11621        let conflict_count = self.conflicts.len();
11622        let rejection_count = self.rejections.len();
11623        let applied_count = report.applied.len();
11624        let rejected_count = report.rejected.len();
11625        let retryable_count = report.retryable.len();
11626        let was_dirty = self.overlay_dirty.snapshot();
11627        let mut removed = Vec::new();
11628        let mut batch = ChangeAccumulator::default();
11629        let persisted = (|| {
11630            for frame in frames {
11631                let Frame::PushResult {
11632                    client_commit_id,
11633                    status,
11634                    results,
11635                    commit_seq,
11636                } = frame
11637                else {
11638                    return Err("unexpected acknowledgement frame".to_owned());
11639                };
11640                if !pushed_ids.contains(client_commit_id.as_str()) {
11641                    continue;
11642                }
11643                let Some(index) = self
11644                    .outbox
11645                    .iter()
11646                    .position(|commit| commit.client_commit_id == *client_commit_id)
11647                else {
11648                    continue;
11649                };
11650                let status = *status;
11651                let rejection_details = rejection_details_by_commit.get(client_commit_id);
11652                let operations = self.outbox[index].ops.clone();
11653                match status {
11654                    PushStatus::Applied | PushStatus::Cached => {
11655                        // §7.2: a lost ack replays as `cached` — proceed as if the
11656                        // ack had arrived.
11657                        let sequence =
11658                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
11659                        let affected: HashSet<_> = operations
11660                            .iter()
11661                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
11662                            .collect();
11663                        let prior = self.acknowledged_operations(Some(&affected), None)?;
11664                        for (op_index, operation) in operations.iter().enumerate() {
11665                            let delivered: bool = self.conn.query_row("SELECT EXISTS(SELECT 1 FROM _syncular_row_deliveries WHERE tbl=?1 AND id=?2 AND commit_seq>=?3)", rusqlite::params![operation.table, operation.row_id, sequence], |row| row.get(0))
11666                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11667                            if delivered {
11668                                continue;
11669                            }
11670                            let table = self
11671                                .schema
11672                                .table(&operation.table)
11673                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
11674                            let mut intended: Map<String, Value> = self
11675                                .stored_row(table, &operation.row_id, true)
11676                                .map(|(row, _)| {
11677                                    table
11678                                        .columns
11679                                        .iter()
11680                                        .zip(&row)
11681                                        .map(|(column, value)| {
11682                                            (column.name.clone(), column_value_to_json(value))
11683                                        })
11684                                        .collect()
11685                                })
11686                                .unwrap_or_default();
11687                            let mut scope_image = intended.clone();
11688                            for op in prior
11689                                .iter()
11690                                .chain(
11691                                    self.failed_commits
11692                                        .iter()
11693                                        .flat_map(|(commit, _)| &commit.ops),
11694                                )
11695                                .chain(
11696                                    self.outbox
11697                                        .iter()
11698                                        .take(index)
11699                                        .flat_map(|commit| &commit.ops),
11700                                )
11701                                .chain(operations.iter().take(op_index + 1))
11702                            {
11703                                if op.table != operation.table || op.row_id != operation.row_id {
11704                                    continue;
11705                                }
11706                                if let Some(values) = &op.values {
11707                                    intended.extend(values.clone());
11708                                } else {
11709                                    scope_image = intended.clone();
11710                                    intended.clear();
11711                                }
11712                            }
11713                            self.conn.execute("INSERT INTO _syncular_acked_rows(commit_id,idx,tbl,id,commit_seq,op_json,intent_json) VALUES (?1,?2,?3,?4,?5,?6,?7)",
11714                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
11715                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
11716                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
11717                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11718                        }
11719                        let journal_results = results
11720                            .iter()
11721                            .map(|result| {
11722                                let op_index = match result {
11723                                    OpResult::Applied { op_index }
11724                                    | OpResult::Conflict { op_index, .. }
11725                                    | OpResult::Error { op_index, .. } => *op_index,
11726                                };
11727                                CommitOperationOutcome::Applied { op_index }
11728                            })
11729                            .collect::<Vec<_>>();
11730                        let outcome_status = if status == PushStatus::Applied {
11731                            CommitOutcomeStatus::Applied
11732                        } else {
11733                            CommitOutcomeStatus::Cached
11734                        };
11735                        let persisted = self
11736                            .persist_commit_outcome(
11737                                client_commit_id,
11738                                outcome_status,
11739                                &journal_results,
11740                                None,
11741                            )
11742                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11743                        persisted?;
11744                        for operation in &operations {
11745                            self.overlay_dirty.table(&operation.table);
11746                        }
11747                        self.sync_needed = true;
11748                        batch.status = true;
11749                        batch.outcomes = true;
11750                    }
11751                    PushStatus::Rejected => {
11752                        if results.iter().any(|result| {
11753                            matches!(
11754                                result,
11755                                OpResult::Error {
11756                                    code,
11757                                    retryable: true,
11758                                    ..
11759                                } if code == "sync.idempotency_cache_miss"
11760                            )
11761                        }) {
11762                            // §6.3/§7.2: a serving failure, not an outcome — keep the
11763                            // exact commit queued for an identical retry.
11764                            report.retryable.push(client_commit_id.to_owned());
11765                            continue;
11766                        }
11767
11768                        let mut journal_results = Vec::with_capacity(results.len());
11769                        let mut conflicts = Vec::new();
11770                        let mut rejections = Vec::new();
11771                        for result in results {
11772                            match result {
11773                                OpResult::Applied { op_index } => {
11774                                    journal_results.push(CommitOperationOutcome::Applied {
11775                                        op_index: *op_index,
11776                                    });
11777                                }
11778                                OpResult::Conflict {
11779                                    op_index,
11780                                    code,
11781                                    message,
11782                                    server_version,
11783                                    server_row,
11784                                    conflict_columns: conflict_columns_bitmap,
11785                                } => {
11786                                    let operation = operations
11787                                        .get(*op_index as usize)
11788                                        .map(CommitOperation::from);
11789                                    let (table, row_id) = operation
11790                                        .as_ref()
11791                                        .map(|op| (op.table.clone(), op.row_id.clone()))
11792                                        .unwrap_or_default();
11793                                    let server_row_json = self
11794                                        .schema
11795                                        .table(&table)
11796                                        .and_then(|t| {
11797                                            decode_row_bytes(t, server_row, &self.encryption)
11798                                                .ok()
11799                                                .map(|row| (t, row))
11800                                        })
11801                                        .map(|(t, row)| {
11802                                            let mut map = Map::new();
11803                                            for (i, column) in t.columns.iter().enumerate() {
11804                                                map.insert(
11805                                                    column.name.clone(),
11806                                                    column_value_to_json(
11807                                                        row.get(i).unwrap_or(&None),
11808                                                    ),
11809                                                );
11810                                            }
11811                                            map
11812                                        })
11813                                        .unwrap_or_default();
11814                                    // §6.3: decode the presence-layout bitmap
11815                                    // into the marked column names.
11816                                    let conflict_columns = self
11817                                        .schema
11818                                        .table(&table)
11819                                        .map(|t| {
11820                                            t.columns
11821                                                .iter()
11822                                                .enumerate()
11823                                                .filter(|(index, _)| {
11824                                                    conflict_columns_bitmap
11825                                                        .get(index / 8)
11826                                                        .is_some_and(|byte| {
11827                                                            byte & (1 << (index % 8)) != 0
11828                                                        })
11829                                                })
11830                                                .map(|(_, column)| column.name.clone())
11831                                                .collect::<Vec<_>>()
11832                                        })
11833                                        .unwrap_or_default();
11834                                    let conflict = ConflictRecord {
11835                                        client_commit_id: client_commit_id.to_owned(),
11836                                        op_index: *op_index,
11837                                        table,
11838                                        row_id,
11839                                        code: code.clone(),
11840                                        message: message.clone(),
11841                                        server_version: *server_version,
11842                                        server_row: server_row_json,
11843                                        conflict_columns,
11844                                        operation,
11845                                    };
11846                                    journal_results.push(CommitOperationOutcome::Conflict {
11847                                        conflict: conflict.clone(),
11848                                    });
11849                                    conflicts.push(conflict);
11850                                }
11851                                OpResult::Error {
11852                                    op_index,
11853                                    code,
11854                                    message,
11855                                    retryable,
11856                                } => {
11857                                    let rejection = RejectionRecord {
11858                                        client_commit_id: client_commit_id.to_owned(),
11859                                        op_index: *op_index,
11860                                        code: code.clone(),
11861                                        message: message.clone(),
11862                                        retryable: *retryable,
11863                                        details: rejection_details
11864                                            .and_then(|details| details.get(op_index))
11865                                            .cloned(),
11866                                        operation: operations
11867                                            .get(*op_index as usize)
11868                                            .map(CommitOperation::from),
11869                                    };
11870                                    journal_results.push(CommitOperationOutcome::Error {
11871                                        rejection: rejection.clone(),
11872                                    });
11873                                    rejections.push(rejection);
11874                                }
11875                            }
11876                        }
11877                        if self.retain_failed_commits {
11878                            self.retain_failed_commit(client_commit_id, &operations)?;
11879                        }
11880                        let outcome_status = if conflicts.is_empty() {
11881                            CommitOutcomeStatus::Rejected
11882                        } else {
11883                            CommitOutcomeStatus::Conflict
11884                        };
11885                        let persisted = self
11886                            .persist_commit_outcome(
11887                                client_commit_id,
11888                                outcome_status,
11889                                &journal_results,
11890                                Some(&operations),
11891                            )
11892                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11893                        persisted?;
11894                        batch.conflicts = !conflicts.is_empty();
11895                        batch.rejections = !rejections.is_empty();
11896                        batch.status = true;
11897                        batch.outcomes = true;
11898                        self.conflicts.extend(conflicts);
11899                        self.rejections.extend(rejections);
11900                        for operation in &operations {
11901                            self.overlay_dirty.table(&operation.table);
11902                        }
11903                    }
11904                }
11905                removed.push((index, self.outbox.remove(index)));
11906                if batch.status {
11907                    for operation in &operations {
11908                        if !self.record_row_scopes(
11909                            &mut batch,
11910                            &operation.table,
11911                            &operation.row_id,
11912                            false,
11913                        ) {
11914                            batch.table(&operation.table);
11915                        }
11916                    }
11917                }
11918                match status {
11919                    PushStatus::Applied | PushStatus::Cached => {
11920                        report.applied.push(client_commit_id.to_owned())
11921                    }
11922                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
11923                }
11924            }
11925            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)) {
11926                self.prune_commit_outcomes()?;
11927            }
11928            self.finish_observation("syncular_push_results", batch)
11929        })();
11930        if let Err(error) = persisted {
11931            self.rollback_observation("syncular_push_results");
11932            self.failed_commits = previous_failed;
11933            self.sync_needed = previous_sync_needed;
11934            for (index, commit) in removed.into_iter().rev() {
11935                self.outbox.insert(index, commit);
11936            }
11937            self.conflicts.truncate(conflict_count);
11938            self.rejections.truncate(rejection_count);
11939            self.overlay_dirty.restore(was_dirty.clone());
11940            report.applied.truncate(applied_count);
11941            report.rejected.truncate(rejected_count);
11942            report.retryable.truncate(retryable_count);
11943            return Err(error);
11944        }
11945        if !previous_sync_needed && self.sync_needed {
11946            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11947        }
11948        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
11949        Ok(())
11950    }
11951
11952    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
11953        status != PushStatus::Rejected
11954            || !results.iter().any(|result| {
11955                matches!(
11956                    result,
11957                    OpResult::Error {
11958                        code,
11959                        retryable: true,
11960                        ..
11961                    } if code == "sync.idempotency_cache_miss"
11962                )
11963            })
11964    }
11965
11966    // -- subscription sections ------------------------------------------------------
11967
11968    #[allow(clippy::too_many_arguments)]
11969    fn process_section(
11970        &mut self,
11971        transport: &mut dyn Transport,
11972        id: &str,
11973        status: SubStatus,
11974        reason_code: &str,
11975        effective_scopes: Vec<(String, Vec<String>)>,
11976        body: Vec<Frame>,
11977        sub_end: Option<(i64, Option<String>)>,
11978        meta: &RequestMeta,
11979        report: &mut SyncReport,
11980    ) -> Result<(), SectionError> {
11981        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11982            return Ok(()); // unknown echo: ignore
11983        };
11984        if status != SubStatus::Active && sub_end.is_none() {
11985            return Err(SectionError::Abort(
11986                "sync.invalid_request".into(),
11987                "subscription section without SUB_END".into(),
11988            ));
11989        }
11990        match status {
11991            SubStatus::Revoked => {
11992                self.begin_observation("syncular_revocation")
11993                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11994                let previous = self.subs[sub_index].clone();
11995                let was_dirty = self.overlay_dirty.snapshot();
11996                let previous_outbox = self.outbox.clone();
11997                let previous_failed = self.failed_commits.clone();
11998                let previous_rejections = self.rejections.clone();
11999                let previous_conflicts = self.conflicts.clone();
12000                let revoked_count = report.revoked.len();
12001                let failed_count = report.failed.len();
12002                let result =
12003                    (|| {
12004                        let mut batch = ChangeAccumulator::default();
12005                        let registered = self.window_unit_by_sub_id(id);
12006                        // §3.3: stop pulling, purge exactly the last effective grant.
12007                        let (table, effective) = {
12008                            let sub = &self.subs[sub_index];
12009                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
12010                        };
12011                        let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
12012                            SectionError::Abort(
12013                                "sync.unknown_table".to_owned(),
12014                                "revoked scope references an unknown table".to_owned(),
12015                            )
12016                        })?;
12017                        let dropped = self
12018                            .drop_failed_commits(|name, values| {
12019                                name == table
12020                                    && !effective.is_empty()
12021                                    && effective.iter().all(|(variable, allowed)| {
12022                                        scoped_table
12023                                            .scope_column(variable)
12024                                            .and_then(|column| values.get(column))
12025                                            .and_then(Value::as_str)
12026                                            .is_some_and(|value| {
12027                                                allowed.iter().any(|allowed| allowed == value)
12028                                            })
12029                                    })
12030                            })
12031                            .map_err(|message| {
12032                                SectionError::Abort("storage.failed".to_owned(), message)
12033                            })?;
12034                        for commit in dropped {
12035                            batch.conflicts = true;
12036                            batch.rejections = true;
12037                            batch.outcomes = true;
12038                            for operation in commit.ops {
12039                                batch.table(&operation.table);
12040                            }
12041                        }
12042                        let purged = self.purge_scope_rows(&table, &effective);
12043                        match purged {
12044                            Ok(()) => {
12045                                self.record_scope_map(&mut batch, &table, &effective);
12046                                let sub = &mut self.subs[sub_index];
12047                                sub.state = SubState::Revoked;
12048                                sub.reason_code = Some(if reason_code.is_empty() {
12049                                    "sync.scope_revoked".to_owned()
12050                                } else {
12051                                    reason_code.to_owned()
12052                                });
12053                                report.revoked.push(id.to_owned());
12054                                let doomed_effective = effective;
12055                                let sub_table = table;
12056                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
12057                                    |message| SectionError::Abort("storage.failed".into(), message),
12058                                )?;
12059                                let dropped = self
12060                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
12061                                    .map_err(|message| {
12062                                        SectionError::Abort("storage.failed".to_owned(), message)
12063                                    })?;
12064                                if dropped {
12065                                    batch.status = true;
12066                                    batch.rejections = true;
12067                                    batch.outcomes = true;
12068                                }
12069                                // §5.9.7 B2: revocation deletes now-unauthorized blob
12070                                // bodies (evicted ≠ revoked).
12071                                self.delete_unreferenced_cached_blobs();
12072                            }
12073                            Err(()) => {
12074                                // §3.3 fail closed: no local mapping — never clear by
12075                                // approximation; fatal configuration error.
12076                                let sub = &mut self.subs[sub_index];
12077                                sub.state = SubState::Failed;
12078                                sub.reason_code = Some("sync.scope_revoked".to_owned());
12079                                report.failed.push(id.to_owned());
12080                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
12081                                    |message| SectionError::Abort("storage.failed".into(), message),
12082                                )?;
12083                            }
12084                        }
12085                        if let Some((base_key, unit)) = registered {
12086                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
12087                        }
12088                        self.rebuild_overlay_if_dirty();
12089                        self.finish_observation("syncular_revocation", batch)
12090                            .map_err(|message| {
12091                                SectionError::Abort("storage.failed".to_owned(), message)
12092                            })?;
12093                        Ok(())
12094                    })();
12095                if result.is_err() {
12096                    self.rollback_observation("syncular_revocation");
12097                    self.subs[sub_index] = previous;
12098                    self.overlay_dirty.restore(was_dirty.clone());
12099                    self.outbox = previous_outbox;
12100                    self.failed_commits = previous_failed;
12101                    self.rejections = previous_rejections;
12102                    self.conflicts = previous_conflicts;
12103                    report.revoked.truncate(revoked_count);
12104                    report.failed.truncate(failed_count);
12105                }
12106                result
12107            }
12108            SubStatus::Reset => {
12109                self.begin_observation("syncular_reset")
12110                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
12111                let previous = self.subs[sub_index].clone();
12112                let was_dirty = self.overlay_dirty.snapshot();
12113                let reset_count = report.resets.len();
12114                let result = (|| {
12115                    let mut batch = ChangeAccumulator::default();
12116                    let registered = self.window_unit_by_sub_id(id);
12117                    // §4.6: discard cursor + bootstrap state, keep local rows —
12118                    // reset is a staleness signal, not a purge signal.
12119                    let sub = &mut self.subs[sub_index];
12120                    sub.cursor = -1;
12121                    sub.bootstrap_state = None;
12122                    report.resets.push(id.to_owned());
12123                    self.persist_sub(&self.subs[sub_index].clone())
12124                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12125                    if let Some((base_key, unit)) = registered {
12126                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
12127                    }
12128                    self.finish_observation("syncular_reset", batch)
12129                        .map_err(|message| {
12130                            SectionError::Abort("storage.failed".to_owned(), message)
12131                        })?;
12132                    Ok(())
12133                })();
12134                if result.is_err() {
12135                    self.rollback_observation("syncular_reset");
12136                    self.subs[sub_index] = previous;
12137                    self.overlay_dirty.restore(was_dirty.clone());
12138                    report.resets.truncate(reset_count);
12139                }
12140                result
12141            }
12142            SubStatus::Active => {
12143                let fresh = meta
12144                    .fresh
12145                    .iter()
12146                    .find(|(fid, _)| fid == id)
12147                    .map(|(_, fresh)| *fresh)
12148                    .unwrap_or(false);
12149                let previous = self.subs[sub_index].clone();
12150                self.subs[sub_index].effective = Some(effective_scopes);
12151                let outcome = self
12152                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
12153                    .and_then(|()| {
12154                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
12155                            SectionError::Abort(
12156                                "sync.invalid_request".into(),
12157                                "subscription section without SUB_END".into(),
12158                            )
12159                        })?;
12160                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
12161                            .map_err(|message| {
12162                                SectionError::Abort("storage.failed".into(), message)
12163                            })
12164                    });
12165                match outcome {
12166                    Ok(()) => {
12167                        if self.subs[sub_index].bootstrap_state.is_some() {
12168                            report.bootstrapping.push(id.to_owned());
12169                        }
12170                        Ok(())
12171                    }
12172                    Err(SectionError::FailClosed) => {
12173                        self.subs[sub_index] = previous.clone();
12174                        self.begin_observation("syncular_section_failure")
12175                            .map_err(|message| {
12176                                SectionError::Abort("storage.failed".into(), message)
12177                            })?;
12178                        let persisted = (|| {
12179                            let mut batch = ChangeAccumulator::default();
12180                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
12181                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
12182                            }
12183                            self.subs[sub_index].state = SubState::Failed;
12184                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
12185                            self.persist_sub(&self.subs[sub_index])?;
12186                            self.finish_observation("syncular_section_failure", batch)
12187                        })();
12188                        if let Err(message) = persisted {
12189                            self.rollback_observation("syncular_section_failure");
12190                            self.subs[sub_index] = previous;
12191                            return Err(SectionError::Abort("storage.failed".into(), message));
12192                        }
12193                        report.failed.push(id.to_owned());
12194                        Ok(())
12195                    }
12196                    Err(error) => {
12197                        // Frame/block transactions already completed. Preserve their
12198                        // rows and revisions, but leave this subscription cursor unchanged.
12199                        self.subs[sub_index] = previous;
12200                        Err(error)
12201                    }
12202                }
12203            }
12204        }
12205    }
12206
12207    fn apply_sub_end(
12208        &mut self,
12209        sub_index: usize,
12210        cursor: i64,
12211        bootstrap_state: Option<String>,
12212    ) -> Result<(), String> {
12213        #[cfg(feature = "bench-internals")]
12214        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
12215        let previous = self.subs[sub_index].clone();
12216        self.begin_observation("syncular_sub_end")?;
12217        let persisted = (|| {
12218            let mut batch = ChangeAccumulator::default();
12219            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
12220                && cursor >= 0
12221                && bootstrap_state.is_none();
12222            if completed {
12223                let table = self
12224                    .schema
12225                    .table(&previous.table)
12226                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
12227                let effective = previous.effective.as_deref().unwrap_or(&[]);
12228                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
12229                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12230                let rows = statement
12231                    .query_map(rusqlite::params![previous.table, cursor], |row| {
12232                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
12233                    })
12234                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12235                let mut covered = HashSet::new();
12236                for row in rows {
12237                    let (id, intent) =
12238                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12239                    let values: Map<String, Value> = serde_json::from_str(&intent)
12240                        .map_err(|_| "sync.local_corrupt".to_owned())?;
12241                    if !effective.is_empty()
12242                        && effective.iter().all(|(variable, allowed)| {
12243                            table
12244                                .scope_column(variable)
12245                                .and_then(|column| values.get(column))
12246                                .is_some_and(|value| {
12247                                    allowed.contains(
12248                                        &value
12249                                            .as_str()
12250                                            .map_or_else(|| value.to_string(), str::to_owned),
12251                                    )
12252                                })
12253                        })
12254                    {
12255                        covered.insert(id);
12256                    }
12257                }
12258                drop(statement);
12259                for id in covered {
12260                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
12261                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12262                    self.overlay_dirty.table(&previous.table);
12263                    batch.table(&previous.table);
12264                }
12265                self.rebuild_overlay_if_dirty();
12266                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
12267                    batch.window(&base_key, &previous.table, &unit);
12268                }
12269            }
12270            let sub = &mut self.subs[sub_index];
12271            sub.cursor = cursor;
12272            sub.bootstrap_state = bootstrap_state;
12273            sub.synced_once = true;
12274            self.persist_sub(&self.subs[sub_index])?;
12275            self.finish_observation("syncular_sub_end", batch)
12276        })();
12277        if persisted.is_err() {
12278            self.rollback_observation("syncular_sub_end");
12279            self.subs[sub_index] = previous;
12280        }
12281        persisted
12282    }
12283
12284    fn apply_section_body(
12285        &mut self,
12286        transport: &mut dyn Transport,
12287        sub_index: usize,
12288        body: Vec<Frame>,
12289        fresh: bool,
12290        meta: &RequestMeta,
12291        report: &mut SyncReport,
12292    ) -> Result<(), SectionError> {
12293        let mut saw_segment = false;
12294        for frame in body {
12295            match frame {
12296                Frame::Commit {
12297                    tables,
12298                    changes,
12299                    commit_seq,
12300                    ..
12301                } => {
12302                    self.apply_commit_frame(&tables, &changes, Some(commit_seq))
12303                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
12304                    report.commits_applied += 1;
12305                }
12306                Frame::SegmentInline { payload } => {
12307                    let segment = decode_rows_segment(&payload)
12308                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
12309                    self.progress.update(|p| {
12310                        p.phase = ProgressPhase::Import;
12311                        p.subscription_id = Some(self.subs[sub_index].id.clone());
12312                        p.table = Some(self.subs[sub_index].table.clone());
12313                        p.segment_id = None;
12314                        p.bytes_received = payload.len() as u64;
12315                        p.bytes_total = Some(payload.len() as u64);
12316                        p.rows_processed = 0;
12317                        p.rows_total =
12318                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
12319                    });
12320                    let first = !saw_segment;
12321                    saw_segment = true;
12322                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
12323                    report.segment_rows_applied += applied;
12324                }
12325                Frame::SegmentRef {
12326                    segment_id,
12327                    byte_length,
12328                    media_type,
12329                    table,
12330                    row_count,
12331                    as_of_commit_seq,
12332                    scope_digest,
12333                    row_cursor,
12334                    next_row_cursor,
12335                    url,
12336                    url_expires_at_ms,
12337                    ..
12338                } => {
12339                    // §4.2: reject a descriptor whose mediaType was not
12340                    // advertised — never skip or guess.
12341                    let advertised = match media_type {
12342                        MediaType::Rows => {
12343                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
12344                                || meta.accept & ACCEPT_INLINE_ROWS != 0
12345                        }
12346                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
12347                    };
12348                    if !advertised {
12349                        return Err(SectionError::Abort(
12350                            "sync.invalid_request".to_owned(),
12351                            format!(
12352                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
12353                                media_type.name()
12354                            ),
12355                        ));
12356                    }
12357                    self.progress.update(|p| {
12358                        p.phase = ProgressPhase::Download;
12359                        p.subscription_id = Some(self.subs[sub_index].id.clone());
12360                        p.table = Some(table.clone());
12361                        p.segment_id = Some(segment_id.clone());
12362                        p.bytes_received = 0;
12363                        p.bytes_total = u64::try_from(byte_length).ok();
12364                        p.rows_processed = 0;
12365                        p.rows_total = u64::try_from(row_count).ok();
12366                    });
12367                    let observer = self.progress.clone();
12368                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
12369                    let bytes = if let Some(url) = url {
12370                        // §5.4: a url-carrying descriptor MUST be fetched
12371                        // from that URL; failure invalidates the whole
12372                        // descriptor (no fall-through to §5.5 — re-pull
12373                        // recovers, §1.4 rule 5).
12374                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
12375                            return Err(SectionError::Abort(
12376                                "sync.invalid_request".to_owned(),
12377                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
12378                                    .to_owned(),
12379                            ));
12380                        }
12381                        // §5.4: MUST NOT start a fetch at/past expiry.
12382                        if url_expires_at_ms.is_some_and(|exp| {
12383                            exp <= transport
12384                                .segment_fetch_started_at(&url)
12385                                .unwrap_or_else(|| self.clock_now_ms())
12386                        }) {
12387                            return Err(SectionError::Abort(
12388                                "sync.segment_expired".to_owned(),
12389                                format!(
12390                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
12391                                ),
12392                            ));
12393                        }
12394                        transport
12395                            .fetch_url(&url, &mut on_progress)
12396                            .map_err(|e| SectionError::Abort(e.code, e.message))?
12397                    } else {
12398                        let requested_scopes_json =
12399                            canonical_scope_json(&self.subs[sub_index].requested);
12400                        transport
12401                            .download_segment(
12402                                &SegmentRequest {
12403                                    segment_id: segment_id.clone(),
12404                                    table,
12405                                    requested_scopes_json,
12406                                },
12407                                &mut on_progress,
12408                            )
12409                            .map_err(|e| SectionError::Abort(e.code, e.message))?
12410                    };
12411                    // §5.1: verify the content address before applying.
12412                    on_progress(bytes.len() as u64);
12413                    let digest = Sha256::digest(&bytes);
12414                    let expected = segment_id
12415                        .strip_prefix("sha256:")
12416                        .unwrap_or(segment_id.as_str());
12417                    if bytes_to_hex(&digest) != expected {
12418                        // §5.1: the client discards the segment and re-pulls.
12419                        self.schedule_background_retry();
12420                        return Err(SectionError::Abort(
12421                            "sync.invalid_request".to_owned(),
12422                            "segment bytes do not match the content address (§5.1)".to_owned(),
12423                        ));
12424                    }
12425                    self.progress.update(|p| p.phase = ProgressPhase::Import);
12426                    if media_type == MediaType::Sqlite {
12427                        // §5.3: images are whole-table — a paged sqlite
12428                        // descriptor is invalid.
12429                        if row_cursor.is_some() || next_row_cursor.is_some() {
12430                            return Err(SectionError::Abort(
12431                                "sync.invalid_request".to_owned(),
12432                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
12433                                    .to_owned(),
12434                            ));
12435                        }
12436                        let first = !saw_segment;
12437                        saw_segment = true;
12438                        let applied = self.apply_sqlite_segment(
12439                            sub_index,
12440                            &bytes,
12441                            fresh && first,
12442                            row_count,
12443                            as_of_commit_seq,
12444                            &scope_digest,
12445                        )?;
12446                        report.segment_rows_applied += applied;
12447                    } else {
12448                        let segment = decode_rows_segment(&bytes).map_err(|e| {
12449                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
12450                        })?;
12451                        let first = row_cursor.is_none();
12452                        saw_segment = true;
12453                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
12454                        report.segment_rows_applied += applied;
12455                    }
12456                }
12457                Frame::Unknown { .. } => {}
12458                Frame::Error { code, message, .. } => {
12459                    return Err(SectionError::Abort(code, message))
12460                }
12461                _ => {
12462                    return Err(SectionError::Abort(
12463                        "sync.invalid_request".to_owned(),
12464                        "unexpected frame inside a subscription section".to_owned(),
12465                    ));
12466                }
12467            }
12468        }
12469        Ok(())
12470    }
12471
12472    // Both pull and realtime frames use the same durable observation boundary.
12473    fn apply_commit_frame(
12474        &mut self,
12475        tables: &[String],
12476        changes: &[ssp2::model::Change],
12477        commit_seq: Option<i64>,
12478    ) -> Result<(), (String, String)> {
12479        #[cfg(feature = "bench-internals")]
12480        let _phase = self.benchmark_phases.start(Phase::CommitApply);
12481        let was_dirty = self.overlay_dirty.snapshot();
12482        self.begin_observation("syncular_commit")
12483            .map_err(|message| ("storage.failed".into(), message))?;
12484        let applied = (|| {
12485            let mut batch = ChangeAccumulator::default();
12486            self.record_commit_changes(&mut batch, tables, changes);
12487            if let Some(sequence) = commit_seq {
12488                for change in changes {
12489                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
12490                        (
12491                            "sync.invalid_request".to_owned(),
12492                            "change tableIndex out of range".to_owned(),
12493                        )
12494                    })?;
12495                    if self
12496                        .outbox
12497                        .iter()
12498                        .flat_map(|commit| &commit.ops)
12499                        .any(|op| op.table == *table && op.row_id == change.row_id)
12500                    {
12501                        self.conn.execute("INSERT INTO _syncular_row_deliveries(tbl,id,commit_seq) VALUES (?1,?2,?3) ON CONFLICT(tbl,id) DO UPDATE SET commit_seq=MAX(commit_seq,excluded.commit_seq)", rusqlite::params![table, change.row_id, sequence])
12502                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
12503                    }
12504                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![table, change.row_id, sequence])
12505                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
12506                }
12507            }
12508            self.apply_commit_changes(tables, changes)?;
12509            self.rebuild_overlay_if_dirty();
12510            self.finish_observation("syncular_commit", batch)
12511                .map_err(|message| ("storage.failed".into(), message))
12512        })();
12513        if applied.is_err() {
12514            self.rollback_observation("syncular_commit");
12515            self.overlay_dirty.restore(was_dirty.clone());
12516        }
12517        applied
12518    }
12519
12520    fn apply_commit_changes(
12521        &mut self,
12522        tables: &[String],
12523        changes: &[ssp2::model::Change],
12524    ) -> Result<(), (String, String)> {
12525        // A clean overlay can reconcile changed primary keys independently
12526        // when their tables have no secondary unique constraints. Such a
12527        // constraint can change whether another row's pending write succeeds,
12528        // so those frames still require the complete FIFO replay below.
12529        let mirror_visible = !self.overlay_dirty.get()
12530            && self.failed_commits.is_empty()
12531            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
12532                || changes.iter().all(|change| {
12533                    tables
12534                        .get(change.table_index as usize)
12535                        .and_then(|name| self.schema.table(name))
12536                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
12537                }));
12538        for change in changes {
12539            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
12540                (
12541                    "sync.invalid_request".to_owned(),
12542                    "change tableIndex out of range".to_owned(),
12543                )
12544            })?;
12545            let table = self.schema.table(table_name).ok_or_else(|| {
12546                (
12547                    "sync.schema_mismatch".to_owned(),
12548                    format!("change targets unknown table {table_name:?}"),
12549                )
12550            })?;
12551            match change.op {
12552                Op::Upsert => {
12553                    let payload = change.row.as_ref().ok_or_else(|| {
12554                        (
12555                            "sync.invalid_request".to_owned(),
12556                            "upsert change without row payload".to_owned(),
12557                        )
12558                    })?;
12559                    // §5.11: decrypt encrypted columns on apply.
12560                    let row = {
12561                        #[cfg(feature = "bench-internals")]
12562                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
12563                        decode_row_bytes(table, payload, &self.encryption)
12564                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
12565                    };
12566                    let version = change.row_version.unwrap_or(0);
12567                    let table_name = table.name.clone();
12568                    self.write_base_row(&table_name, &row, version)
12569                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
12570                    if mirror_visible {
12571                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
12572                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
12573                    }
12574                }
12575                Op::Delete => {
12576                    self.delete_base_row(table_name, &change.row_id)
12577                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
12578                    if mirror_visible {
12579                        let sql = format!(
12580                            "DELETE FROM {} WHERE {}",
12581                            visible_table(table_name),
12582                            row_id_predicate(table)
12583                        );
12584                        self.conn
12585                            .prepare_cached(&sql)
12586                            .and_then(|mut statement| {
12587                                statement.execute(rusqlite::params![change.row_id])
12588                            })
12589                            .map_err(|error| {
12590                                ("sync.invalid_request".to_owned(), error.to_string())
12591                            })?;
12592                    }
12593                }
12594            }
12595        }
12596        if mirror_visible {
12597            {
12598                let changed_rows: HashSet<(&str, &str)> = changes
12599                    .iter()
12600                    .map(|change| {
12601                        (
12602                            tables[change.table_index as usize].as_str(),
12603                            change.row_id.as_str(),
12604                        )
12605                    })
12606                    .collect();
12607                let acknowledged = self
12608                    .acknowledged_operations(Some(&changed_rows), None)
12609                    .map_err(|error| ("storage.failed".to_owned(), error))?;
12610                self.apply_outbox_ops(&acknowledged);
12611                self.apply_outbox_ops(
12612                    self.outbox
12613                        .iter()
12614                        .flat_map(|commit| &commit.ops)
12615                        .filter(|op| {
12616                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
12617                        }),
12618                );
12619            }
12620            self.overlay_dirty.set(false);
12621        }
12622        Ok(())
12623    }
12624
12625    /// §5.6 segment application: validate against the generated schema,
12626    /// clear the grant on a fresh bootstrap's first page (fail closed
12627    /// without a mapping), then replace-or-upsert each row with its
12628    /// segment-carried server version (§5.2).
12629    fn apply_segment(
12630        &mut self,
12631        sub_index: usize,
12632        segment: &RowsSegment,
12633        first_fresh_page: bool,
12634    ) -> Result<u32, SectionError> {
12635        let (sub_table, effective) = {
12636            let sub = &self.subs[sub_index];
12637            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
12638        };
12639        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
12640            SectionError::Abort(
12641                "sync.schema_mismatch".to_owned(),
12642                format!("subscription table {sub_table:?} is not in the client schema"),
12643            )
12644        })?;
12645        // §5.2: the column table validates against the generated schema —
12646        // order, names, types, nullability; mismatch is fatal. §5.11: the
12647        // server sends the WIRE types (bytes for an encrypted column), so
12648        // validate against wire_columns.
12649        let matches = segment.table == table.name
12650            && segment.schema_version == self.schema.version
12651            && segment.columns.len() == table.wire_columns.len()
12652            && segment
12653                .columns
12654                .iter()
12655                .zip(table.wire_columns.iter())
12656                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
12657        if !matches {
12658            return Err(SectionError::Abort(
12659                "sync.schema_mismatch".to_owned(),
12660                "segment column table does not match the generated schema (§5.2)".to_owned(),
12661            ));
12662        }
12663        let mut applied = 0u32;
12664        let empty = Vec::new();
12665        let blocks = if segment.blocks.is_empty() {
12666            std::slice::from_ref(&empty)
12667        } else {
12668            &segment.blocks
12669        };
12670        for (index, block) in blocks.iter().enumerate() {
12671            let clear = first_fresh_page && index == 0;
12672            if !clear && block.is_empty() {
12673                continue;
12674            }
12675            let was_dirty = self.overlay_dirty.snapshot();
12676            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12677                && self
12678                    .outbox
12679                    .iter()
12680                    .flat_map(|commit| &commit.ops)
12681                    .any(|op| op.table == table.name);
12682            let mirror_visible = !reconcile_pending
12683                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12684            self.begin_observation("syncular_segment_block")
12685                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12686            let outcome = (|| {
12687                self.rebuild_overlay_if_dirty();
12688                let mut changed_keys = Vec::new();
12689                let mut batch = ChangeAccumulator::default();
12690                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
12691                    batch.table(&table.name);
12692                }
12693                if clear {
12694                    self.purge_scope_rows(&table.name, &effective)
12695                        .map_err(|()| SectionError::FailClosed)?;
12696                }
12697                for (row_index, row) in block.iter().enumerate() {
12698                    let decrypted;
12699                    let values = if table.has_encrypted_columns() {
12700                        let mut values = row.values.clone();
12701                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
12702                            .map_err(|message| {
12703                            SectionError::Abort("client.decrypt_failed".into(), message)
12704                        })?;
12705                        decrypted = values;
12706                        &decrypted
12707                    } else {
12708                        &row.values
12709                    };
12710                    self.write_base_row(&table.name, values, row.server_version)
12711                        .map_err(|message| {
12712                            SectionError::Abort("sync.invalid_request".into(), message)
12713                        })?;
12714                    if reconcile_pending {
12715                        changed_keys.push(
12716                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
12717                                |message| SectionError::Abort("storage.failed".into(), message),
12718                            )?,
12719                        );
12720                    }
12721                    if mirror_visible {
12722                        self.write_row(
12723                            &visible_table(&table.name),
12724                            &table.name,
12725                            values,
12726                            row.server_version,
12727                        )
12728                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12729                    }
12730                    let processed = u64::from(applied) + row_index as u64 + 1;
12731                    if processed.is_multiple_of(1024) {
12732                        self.progress.update(|p| p.rows_processed = processed);
12733                    }
12734                }
12735                if reconcile_pending {
12736                    self.reconcile_imported_rows(&table, &mut changed_keys)
12737                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12738                }
12739                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
12740                    self.apply_outbox_ops(
12741                        self.outbox
12742                            .iter()
12743                            .flat_map(|commit| &commit.ops)
12744                            .filter(|op| op.table == table.name),
12745                    );
12746                    self.overlay_dirty.set(false);
12747                } else {
12748                    self.rebuild_overlay_if_dirty();
12749                }
12750                self.finish_observation("syncular_segment_block", batch)
12751                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
12752            })();
12753            if let Err(error) = outcome {
12754                self.rollback_observation("syncular_segment_block");
12755                self.overlay_dirty.restore(was_dirty.clone());
12756                return Err(error);
12757            }
12758            applied += block.len() as u32;
12759            self.progress
12760                .update(|p| p.rows_processed = u64::from(applied));
12761            std::thread::yield_now();
12762        }
12763        self.progress
12764            .update(|p| p.rows_processed = u64::from(applied));
12765        Ok(applied)
12766    }
12767
12768    /// §5.3 sqlite-image application: validate the in-file metadata
12769    /// against the descriptor, validate column names/order against the
12770    /// generated schema, run the §5.6 first-page clear when fresh, then
12771    /// replace-or-upsert every image row with its `_syncular_version`.
12772    /// Mechanics: the image lands in a temp file read through a second
12773    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
12774    /// Each image chunk commits its writes and revision before readers continue.
12775    fn apply_sqlite_segment(
12776        &mut self,
12777        sub_index: usize,
12778        bytes: &[u8],
12779        first_fresh_page: bool,
12780        row_count: i64,
12781        as_of_commit_seq: i64,
12782        scope_digest: &str,
12783    ) -> Result<u32, SectionError> {
12784        let invalid = |detail: &str| {
12785            SectionError::Abort(
12786                "sync.invalid_request".to_owned(),
12787                format!("sqlite segment rejected: {detail} (§5.3)"),
12788            )
12789        };
12790        let (sub_table, effective) = {
12791            let sub = &self.subs[sub_index];
12792            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
12793        };
12794        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
12795            SectionError::Abort(
12796                "sync.schema_mismatch".to_owned(),
12797                format!("subscription table {sub_table:?} is not in the client schema"),
12798            )
12799        })?;
12800
12801        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
12802        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
12803        let img = match rusqlite::Connection::open_with_flags(
12804            &path,
12805            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
12806        ) {
12807            Ok(conn) => conn,
12808            Err(_) => {
12809                let _ = std::fs::remove_file(&path);
12810                return Err(invalid("bytes do not open as a SQLite database"));
12811            }
12812        };
12813        let outcome = self.apply_sqlite_image(
12814            &img,
12815            &table,
12816            first_fresh_page,
12817            &effective,
12818            row_count,
12819            as_of_commit_seq,
12820            scope_digest,
12821        );
12822        drop(img);
12823        let _ = std::fs::remove_file(&path);
12824        outcome
12825    }
12826
12827    #[allow(clippy::too_many_arguments)]
12828    fn apply_sqlite_image(
12829        &mut self,
12830        img: &rusqlite::Connection,
12831        table: &crate::schema::TableSchema,
12832        first_fresh_page: bool,
12833        effective: &[(String, Vec<String>)],
12834        row_count: i64,
12835        as_of_commit_seq: i64,
12836        scope_digest: &str,
12837    ) -> Result<u32, SectionError> {
12838        let invalid = |detail: String| {
12839            SectionError::Abort(
12840                "sync.invalid_request".to_owned(),
12841                format!("sqlite segment rejected: {detail} (§5.3)"),
12842            )
12843        };
12844
12845        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
12846        //    one row).
12847        type MetaRow = (i64, String, i64, i64, String, i64, i64);
12848        let meta: MetaRow = img
12849            .query_row(
12850                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
12851                        \"scopeDigest\", \"rowCount\",
12852                        (SELECT count(*) FROM _syncular_segment)
12853                 FROM _syncular_segment",
12854                [],
12855                |row| {
12856                    Ok((
12857                        row.get(0)?,
12858                        row.get(1)?,
12859                        row.get(2)?,
12860                        row.get(3)?,
12861                        row.get(4)?,
12862                        row.get(5)?,
12863                        row.get(6)?,
12864                    ))
12865                },
12866            )
12867            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
12868        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
12869        if meta_count != 1 {
12870            return Err(invalid(format!(
12871                "_syncular_segment must contain exactly one row, found {meta_count}"
12872            )));
12873        }
12874        if format != 1 {
12875            return Err(invalid(format!("format {format}")));
12876        }
12877        if meta_table != table.name {
12878            return Err(invalid(format!("image table {meta_table:?}")));
12879        }
12880        if schema_version != i64::from(self.schema.version) {
12881            return Err(invalid(format!("schemaVersion {schema_version}")));
12882        }
12883        if pin != as_of_commit_seq {
12884            return Err(invalid(format!("asOfCommitSeq {pin}")));
12885        }
12886        if digest != scope_digest {
12887            return Err(invalid("scopeDigest mismatch".to_owned()));
12888        }
12889        if meta_rows != row_count {
12890            return Err(invalid(format!("rowCount {meta_rows}")));
12891        }
12892
12893        // 2. Column names and order vs the generated schema (§5.3 rule 3).
12894        let mut names: Vec<String> = Vec::new();
12895        let mut primary_keys: Vec<String> = Vec::new();
12896        {
12897            let mut stmt = img
12898                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
12899                .map_err(|_| invalid("image data table missing".to_owned()))?;
12900            let mut rows = stmt
12901                .query([])
12902                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
12903            while let Some(row) = rows
12904                .next()
12905                .map_err(|_| invalid("image data table unreadable".to_owned()))?
12906            {
12907                if row
12908                    .get::<_, i64>(5)
12909                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
12910                    > 0
12911                {
12912                    primary_keys.push(row.get(1).map_err(|error| {
12913                        invalid(Self::sqlite_failure(&self.storage_failure, error))
12914                    })?);
12915                }
12916                names.push(
12917                    row.get::<_, String>(1)
12918                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
12919                );
12920            }
12921        }
12922        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
12923        expected.push("_syncular_version");
12924        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
12925            return Err(SectionError::Abort(
12926                "sync.schema_mismatch".to_owned(),
12927                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
12928            ));
12929        }
12930
12931        if primary_keys != vec![table.primary_key.clone()] {
12932            return Err(invalid(
12933                "image primary key does not match generated schema".into(),
12934            ));
12935        }
12936        let actual_count: i64 = img
12937            .query_row(
12938                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
12939                [],
12940                |row| row.get(0),
12941            )
12942            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12943        if actual_count != row_count {
12944            return Err(invalid("image row count does not match descriptor".into()));
12945        }
12946        let insert = self.insert_row_sql(&base_table(&table.name), table);
12947        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
12948        let column_list = names
12949            .iter()
12950            .map(|n| quote_ident(n))
12951            .collect::<Vec<_>>()
12952            .join(", ");
12953        let mut stmt = img
12954            .prepare(&format!(
12955                "SELECT {column_list} FROM {} ORDER BY {}",
12956                quote_ident(&table.name),
12957                quote_ident(&table.primary_key)
12958            ))
12959            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12960        let mut rows = stmt
12961            .query([])
12962            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12963        let mut applied = 0u32;
12964        loop {
12965            let clear = applied == 0 && first_fresh_page;
12966            let was_dirty = self.overlay_dirty.snapshot();
12967            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12968                && self
12969                    .outbox
12970                    .iter()
12971                    .flat_map(|commit| &commit.ops)
12972                    .any(|op| op.table == table.name);
12973            let mirror_visible = !reconcile_pending
12974                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12975            self.begin_observation("syncular_image_chunk")
12976                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12977            let result = (|| {
12978                self.rebuild_overlay_if_dirty();
12979                let mut changed_keys = Vec::new();
12980                let mut batch = ChangeAccumulator::default();
12981                if clear {
12982                    if self.scoped_rows_exist(&table.name, effective) {
12983                        batch.table(&table.name);
12984                    }
12985                    self.purge_scope_rows(&table.name, effective)
12986                        .map_err(|()| SectionError::FailClosed)?;
12987                }
12988                let mut processed = 0u32;
12989                {
12990                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
12991                        invalid(Self::sqlite_failure(&self.storage_failure, error))
12992                    })?;
12993                    let mut mirror = if mirror_visible {
12994                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
12995                            invalid(Self::sqlite_failure(&self.storage_failure, error))
12996                        })?)
12997                    } else {
12998                        None
12999                    };
13000                    for _ in 0..1024 {
13001                        let Some(row) = rows.next().map_err(|error| {
13002                            invalid(Self::sqlite_failure(&self.storage_failure, error))
13003                        })?
13004                        else {
13005                            break;
13006                        };
13007                        for (i, column) in table.columns.iter().enumerate() {
13008                            let cell = row.get_ref(i).map_err(|error| {
13009                                invalid(Self::sqlite_failure(&self.storage_failure, error))
13010                            })?;
13011                            let param = image_cell_param(column, cell).map_err(invalid)?;
13012                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
13013                                invalid(Self::sqlite_failure(&self.storage_failure, error))
13014                            })?;
13015                            if let Some(mirror) = &mut mirror {
13016                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
13017                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
13018                                })?;
13019                            }
13020                        }
13021                        let version_index = table.columns.len();
13022                        let version: i64 = row.get(version_index).map_err(|error| {
13023                            invalid(Self::sqlite_failure(&self.storage_failure, error))
13024                        })?;
13025                        if version < 1 {
13026                            return Err(invalid("image row version must be positive".into()));
13027                        }
13028                        ins.raw_bind_parameter(version_index + 1, version)
13029                            .map_err(|error| {
13030                                invalid(Self::sqlite_failure(&self.storage_failure, error))
13031                            })?;
13032                        ins.raw_execute().map_err(|error| {
13033                            invalid(Self::sqlite_failure(&self.storage_failure, error))
13034                        })?;
13035                        if let Some(mirror) = &mut mirror {
13036                            mirror
13037                                .raw_bind_parameter(version_index + 1, version)
13038                                .map_err(|error| {
13039                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
13040                                })?;
13041                            mirror.raw_execute().map_err(|error| {
13042                                invalid(Self::sqlite_failure(&self.storage_failure, error))
13043                            })?;
13044                        }
13045                        if reconcile_pending {
13046                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
13047                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
13048                            )?);
13049                        }
13050                        processed += 1;
13051                    }
13052                }
13053                if processed > 0 {
13054                    batch.table(&table.name);
13055                    self.overlay_dirty.table(&table.name);
13056                }
13057                if reconcile_pending {
13058                    self.reconcile_imported_rows(table, &mut changed_keys)
13059                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
13060                }
13061                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
13062                    self.apply_outbox_ops(
13063                        self.outbox
13064                            .iter()
13065                            .flat_map(|commit| &commit.ops)
13066                            .filter(|op| op.table == table.name),
13067                    );
13068                    self.overlay_dirty.set(false);
13069                } else {
13070                    self.rebuild_overlay_if_dirty();
13071                }
13072                self.finish_observation("syncular_image_chunk", batch)
13073                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
13074                Ok(processed)
13075            })();
13076            let processed = match result {
13077                Ok(processed) => processed,
13078                Err(error) => {
13079                    self.rollback_observation("syncular_image_chunk");
13080                    self.overlay_dirty.restore(was_dirty.clone());
13081                    return Err(error);
13082                }
13083            };
13084            applied += processed;
13085            self.progress
13086                .update(|p| p.rows_processed = u64::from(applied));
13087            std::thread::yield_now();
13088            if i64::from(applied) == actual_count {
13089                break;
13090            }
13091        }
13092        Ok(applied)
13093    }
13094
13095    /// Restore only imported and pending row identities before FIFO replay. All pending
13096    /// identities participate: a server row can free a unique value for a different edit.
13097    /// The caller owns the chunk transaction and publishes its revision after replay.
13098    fn reconcile_imported_rows(
13099        &self,
13100        table: &TableSchema,
13101        keys: &mut Vec<SqlValue>,
13102    ) -> Result<(), String> {
13103        let visible = visible_table(&table.name);
13104        let base = base_table(&table.name);
13105        let pk = quote_ident(&table.primary_key);
13106        let predicate = row_id_predicate(table);
13107        let mut lookup = self.conn.prepare_cached(&format!(
13108            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
13109        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13110        let mut deleted = HashSet::new();
13111        for op in self
13112            .outbox
13113            .iter()
13114            .flat_map(|commit| &commit.ops)
13115            .filter(|op| op.table == table.name)
13116        {
13117            if op.upsert {
13118                if let Some(values) = &op.values {
13119                    let key = json_to_column_value(
13120                        &table.columns[table.pk_index],
13121                        values.get(&table.primary_key),
13122                    )?;
13123                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
13124                }
13125            } else if deleted.insert(&op.row_id) {
13126                keys.extend(
13127                    lookup
13128                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
13129                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13130                        .collect::<Result<Vec<_>, _>>()
13131                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13132                );
13133            }
13134        }
13135        // Delete the complete affected set before restoring any base value, so
13136        // old optimistic unique values cannot collide with the new server rows.
13137        for chunk in keys.chunks(400) {
13138            let holes = vec!["?"; chunk.len()].join(",");
13139            self.conn
13140                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
13141                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
13142                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13143        }
13144        let updates = table
13145            .columns
13146            .iter()
13147            .map(|column| column.name.as_str())
13148            .chain(std::iter::once("_syncular_version"))
13149            .filter(|name| *name != table.primary_key)
13150            .map(|name| {
13151                let name = quote_ident(name);
13152                format!("{name}=excluded.{name}")
13153            })
13154            .collect::<Vec<_>>()
13155            .join(",");
13156        for chunk in keys.chunks(400) {
13157            let holes = vec!["?"; chunk.len()].join(",");
13158            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
13159                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
13160                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13161        }
13162        Ok(())
13163    }
13164
13165    // -- application-authorized local purge ---------------------------------------
13166
13167    fn compile_local_data_purge(
13168        &self,
13169        input: &LocalDataPurgeInput,
13170    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
13171        let invalid = |message: String| format!("sync.invalid_request: {message}");
13172        if input.purge_id.is_empty()
13173            || input.purge_id.len() > 128
13174            || !is_local_operation_code_like(&input.purge_id)
13175        {
13176            return Err(invalid(
13177                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
13178            ));
13179        }
13180        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
13181            return Err(invalid(format!(
13182                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
13183            )));
13184        }
13185
13186        let mut deduplicated: BTreeMap<
13187            String,
13188            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
13189        > = BTreeMap::new();
13190        for target in &input.targets {
13191            let table = self.schema.table(&target.table).ok_or_else(|| {
13192                invalid(format!(
13193                    "local purge names unknown table {:?}",
13194                    target.table
13195                ))
13196            })?;
13197            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
13198                return Err(invalid(format!(
13199                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
13200                    target.table
13201                )));
13202            }
13203            let mut selectors = Vec::with_capacity(target.selectors.len());
13204            let mut canonical_selectors = BTreeMap::new();
13205            for (column_name, raw_values) in &target.selectors {
13206                let Some((column_index, column)) = table
13207                    .columns
13208                    .iter()
13209                    .enumerate()
13210                    .find(|(_, column)| column.name == *column_name)
13211                else {
13212                    return Err(invalid(format!(
13213                        "local purge target {:?} names unknown column {:?}",
13214                        target.table, column_name
13215                    )));
13216                };
13217                let encrypted = table
13218                    .encrypted_columns
13219                    .iter()
13220                    .any(|candidate| candidate.index == column_index);
13221                if column.ty != ColumnType::String || encrypted {
13222                    return Err(invalid(format!(
13223                        "local purge selector {:?}.{:?} must be a plaintext string column",
13224                        target.table, column_name
13225                    )));
13226                }
13227                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
13228                    return Err(invalid(format!(
13229                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
13230                        target.table, column_name
13231                    )));
13232                }
13233                let mut values = raw_values.clone();
13234                values.sort();
13235                values.dedup();
13236                if values.iter().any(|value| {
13237                    value.is_empty()
13238                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
13239                        || !is_local_operation_code_like(value)
13240                }) {
13241                    return Err(invalid(format!(
13242                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
13243                    )));
13244                }
13245                selectors.push((column_name.clone(), values.clone()));
13246                canonical_selectors.insert(column_name.clone(), values);
13247            }
13248            let canonical = LocalDataPurgeTarget {
13249                table: target.table.clone(),
13250                selectors: canonical_selectors,
13251            };
13252            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
13253            deduplicated.insert(
13254                key,
13255                (
13256                    CompiledLocalDataPurgeTarget {
13257                        table: target.table.clone(),
13258                        selectors,
13259                    },
13260                    canonical,
13261                ),
13262            );
13263        }
13264        let targets = deduplicated
13265            .values()
13266            .map(|(compiled, _)| compiled.clone())
13267            .collect::<Vec<_>>();
13268        let canonical = deduplicated
13269            .values()
13270            .map(|(_, target)| target.clone())
13271            .collect::<Vec<_>>();
13272        let canonical_plan =
13273            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
13274        Ok((targets, canonical_plan))
13275    }
13276
13277    fn local_purge_base_row_ids(
13278        &self,
13279        targets: &[CompiledLocalDataPurgeTarget],
13280    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
13281        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
13282        for target in targets {
13283            let table = self
13284                .schema
13285                .table(&target.table)
13286                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
13287            let mut clauses = Vec::with_capacity(target.selectors.len());
13288            let mut params = Vec::new();
13289            for (column, values) in &target.selectors {
13290                clauses.push(format!(
13291                    "{} IN ({})",
13292                    quote_ident(column),
13293                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
13294                ));
13295                params.extend(values.iter().cloned().map(SqlValue::Text));
13296            }
13297            let sql = format!(
13298                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
13299                quote_ident(&table.primary_key),
13300                base_table(&target.table),
13301                clauses.join(" AND ")
13302            );
13303            let mut statement = self
13304                .conn
13305                .prepare(&sql)
13306                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13307            let rows = statement
13308                .query_map(rusqlite::params_from_iter(params), |row| {
13309                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
13310                    render_row_id_json(Some(&value))
13311                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
13312                })
13313                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13314            let ids = by_table.entry(target.table.clone()).or_default();
13315            for row in rows {
13316                ids.insert(
13317                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13318                );
13319            }
13320        }
13321        Ok(by_table)
13322    }
13323
13324    fn local_purge_values_match(
13325        target: &CompiledLocalDataPurgeTarget,
13326        values: &Map<String, Value>,
13327    ) -> bool {
13328        target.selectors.iter().all(|(column, allowed)| {
13329            values
13330                .get(column)
13331                .and_then(Value::as_str)
13332                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
13333        })
13334    }
13335
13336    /// Apply one host-authorized local security purge. The host MUST gate the
13337    /// corresponding subscriptions first; this primitive owns local SQLite
13338    /// cleanup and never grants/revokes server authority by itself.
13339    pub fn purge_local_data(
13340        &mut self,
13341        input: &LocalDataPurgeInput,
13342    ) -> Result<LocalDataPurgeResult, String> {
13343        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
13344        self.cancel_sync_round();
13345        let meta_key = format!("localPurge:{}", input.purge_id);
13346        let applied_plan = self
13347            .conn
13348            .query_row(
13349                "SELECT value FROM _syncular_meta WHERE key = ?1",
13350                rusqlite::params![meta_key],
13351                |row| row.get::<_, String>(0),
13352            )
13353            .optional()
13354            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13355        if let Some(applied_plan) = applied_plan {
13356            if applied_plan != canonical_plan {
13357                return Err(format!(
13358                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
13359                    input.purge_id
13360                ));
13361            }
13362            return Ok(LocalDataPurgeResult {
13363                already_applied: true,
13364                purged_rows: 0,
13365                dropped_commits: 0,
13366            });
13367        }
13368
13369        // RFC 0005 D7: the container file cannot be a purge target (its name is
13370        // not in the running schema), so purge drops it as a fixed step, before
13371        // any mirror row is touched and unconditionally.
13372        let _ = self.drop_previous_version();
13373
13374        let prior_outbox = self.outbox.clone();
13375        let prior_failed = self.failed_commits.clone();
13376        let prior_rejections = self.rejections.clone();
13377        let prior_conflicts = self.conflicts.clone();
13378        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13379        self.begin_observation("syncular_local_purge")?;
13380        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
13381            let row_ids = self.local_purge_base_row_ids(&targets)?;
13382            let retained_dropped = self.drop_failed_commits(|table, values| {
13383                targets.iter().any(|target| {
13384                    target.table == table && Self::local_purge_values_match(target, values)
13385                })
13386            })?;
13387            let doomed = self
13388                .outbox
13389                .iter()
13390                .filter(|commit| {
13391                    commit.ops.iter().any(|operation| {
13392                        let matching_targets = targets
13393                            .iter()
13394                            .filter(|target| target.table == operation.table)
13395                            .collect::<Vec<_>>();
13396                        if matching_targets.is_empty() {
13397                            return false;
13398                        }
13399                        if row_ids
13400                            .get(&operation.table)
13401                            .is_some_and(|ids| ids.contains(&operation.row_id))
13402                        {
13403                            return true;
13404                        }
13405                        operation.values.as_ref().is_some_and(|values| {
13406                            matching_targets
13407                                .iter()
13408                                .any(|target| Self::local_purge_values_match(target, values))
13409                        })
13410                    })
13411                })
13412                .cloned()
13413                .collect::<Vec<_>>();
13414            let doomed_ids = doomed
13415                .iter()
13416                .map(|commit| commit.client_commit_id.clone())
13417                .collect::<BTreeSet<_>>();
13418            let mut batch = ChangeAccumulator::default();
13419            for commit in &retained_dropped {
13420                batch.conflicts = true;
13421                batch.rejections = true;
13422                batch.outcomes = true;
13423                for operation in &commit.ops {
13424                    batch.table(&operation.table);
13425                }
13426            }
13427            let mut rejections = Vec::new();
13428            for commit in &doomed {
13429                for operation in &commit.ops {
13430                    batch.table(&operation.table);
13431                }
13432                let results = commit
13433                    .ops
13434                    .iter()
13435                    .enumerate()
13436                    .map(|(op_index, operation)| {
13437                        let rejection = RejectionRecord {
13438                            client_commit_id: commit.client_commit_id.clone(),
13439                            op_index: op_index as i32,
13440                            code: "client.local_data_purged".to_owned(),
13441                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
13442                            retryable: false,
13443                            details: None,
13444                            operation: Some(CommitOperation::from(operation)),
13445                        };
13446                        rejections.push(rejection.clone());
13447                        CommitOperationOutcome::Error { rejection }
13448                    })
13449                    .collect::<Vec<_>>();
13450                self.persist_commit_outcome(
13451                    &commit.client_commit_id,
13452                    CommitOutcomeStatus::Rejected,
13453                    &results,
13454                    Some(&commit.ops),
13455                )?;
13456                self.delete_outbox_persisted(&commit.client_commit_id)?;
13457            }
13458            if !doomed.is_empty() {
13459                self.outbox
13460                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
13461                self.rejections.extend(rejections);
13462                self.prune_commit_outcomes()?;
13463                batch.status = true;
13464                batch.rejections = true;
13465                batch.outcomes = true;
13466            }
13467
13468            let mut purged_rows = 0usize;
13469            for (table, ids) in &row_ids {
13470                if ids.is_empty() {
13471                    continue;
13472                }
13473                batch.table(table);
13474                for row_id in ids {
13475                    self.delete_base_row(table, row_id)?;
13476                    purged_rows += 1;
13477                }
13478            }
13479            self.rebuild_overlay_if_dirty();
13480            self.delete_unreferenced_cached_blobs();
13481            self.conn
13482                .execute(
13483                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13484                    rusqlite::params![meta_key, canonical_plan],
13485                )
13486                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13487            Ok((
13488                batch,
13489                LocalDataPurgeResult {
13490                    already_applied: false,
13491                    purged_rows,
13492                    dropped_commits: doomed.len(),
13493                },
13494            ))
13495        })();
13496        let (batch, result) = match applied {
13497            Ok(value) => value,
13498            Err(error) => {
13499                self.rollback_observation("syncular_local_purge");
13500                self.outbox = prior_outbox;
13501                self.failed_commits = prior_failed;
13502                self.rejections = prior_rejections;
13503                self.conflicts = prior_conflicts;
13504                self.overlay_dirty.restore(prior_overlay_dirty.clone());
13505                return Err(error);
13506            }
13507        };
13508        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
13509            self.rollback_observation("syncular_local_purge");
13510            self.outbox = prior_outbox;
13511            self.failed_commits = prior_failed;
13512            self.rejections = prior_rejections;
13513            self.conflicts = prior_conflicts;
13514            self.overlay_dirty.restore(prior_overlay_dirty.clone());
13515            return Err(error);
13516        }
13517        Ok(result)
13518    }
13519
13520    /// Application-authorized recovery of the replicated projection. Unlike a
13521    /// local security purge, this retains the entire outbox, device identity,
13522    /// lease, outcomes, subscription registrations, and protected bookkeeping.
13523    /// The projection reset, subscription rewind, optimistic replay, and
13524    /// idempotency marker share one savepoint for interruption safety.
13525    pub fn rebootstrap_local_data(
13526        &mut self,
13527        input: &LocalDataRebootstrapInput,
13528    ) -> Result<LocalDataRebootstrapResult, String> {
13529        if input.rebootstrap_id.is_empty()
13530            || input.rebootstrap_id.len() > 128
13531            || !is_local_operation_code_like(&input.rebootstrap_id)
13532        {
13533            return Err(
13534                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
13535                    .to_owned(),
13536            );
13537        }
13538        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
13539        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
13540            let (retained_commits, reset_subscriptions) =
13541                decode_local_rebootstrap_receipt(&persisted)?;
13542            return Ok(LocalDataRebootstrapResult {
13543                already_applied: true,
13544                retained_commits,
13545                reset_subscriptions,
13546            });
13547        }
13548        if self.stopped || self.schema_floor.is_some() {
13549            return Err(
13550                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
13551                    .to_owned(),
13552            );
13553        }
13554
13555        let retained_commits = self.outbox.len();
13556        let reset_subscriptions = self.subs.len();
13557        let prior_subs = self.subs.clone();
13558        let prior_upgrading = self.upgrading;
13559        let prior_stopped = self.stopped;
13560        let prior_schema_floor = self.schema_floor.clone();
13561        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13562        let prior_sync_needed = self.sync_needed;
13563        let prior_sync_intents = self.sync_intent_queue.clone();
13564        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
13565
13566        self.cancel_sync_round();
13567        self.begin_observation("syncular_local_rebootstrap")?;
13568        let mut batch = ChangeAccumulator::default();
13569        let applied = (|| -> Result<(), String> {
13570            self.run_schema_reset_observed(&mut batch, false, false)?;
13571            self.conn
13572                .execute(
13573                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13574                    rusqlite::params![meta_key, receipt],
13575                )
13576                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13577            self.sync_needed = true;
13578            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13579            batch.status = true;
13580            Ok(())
13581        })();
13582        if let Err(error) = applied {
13583            self.rollback_observation("syncular_local_rebootstrap");
13584            self.subs = prior_subs;
13585            self.upgrading = prior_upgrading;
13586            self.stopped = prior_stopped;
13587            self.schema_floor = prior_schema_floor;
13588            self.overlay_dirty.restore(prior_overlay_dirty.clone());
13589            self.sync_needed = prior_sync_needed;
13590            self.sync_intent_queue = prior_sync_intents;
13591            return Err(error);
13592        }
13593        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
13594            self.rollback_observation("syncular_local_rebootstrap");
13595            self.subs = prior_subs;
13596            self.upgrading = prior_upgrading;
13597            self.stopped = prior_stopped;
13598            self.schema_floor = prior_schema_floor;
13599            self.overlay_dirty.restore(prior_overlay_dirty.clone());
13600            self.sync_needed = prior_sync_needed;
13601            self.sync_intent_queue = prior_sync_intents;
13602            return Err(error);
13603        }
13604
13605        Ok(LocalDataRebootstrapResult {
13606            already_applied: false,
13607            retained_commits,
13608            reset_subscriptions,
13609        })
13610    }
13611
13612    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
13613
13614    /// Delete base rows matching the effective scopes; `Err(())` = no local
13615    /// scope-column mapping for a key (the fail-closed case).
13616    fn purge_scope_rows(
13617        &mut self,
13618        table_name: &str,
13619        effective: &[(String, Vec<String>)],
13620    ) -> Result<(), ()> {
13621        if effective.is_empty() {
13622            return Ok(());
13623        }
13624        let table = self.schema.table(table_name).ok_or(())?.clone();
13625        let mut clauses = Vec::new();
13626        let mut params: Vec<SqlValue> = Vec::new();
13627        for (variable, values) in effective {
13628            let column = table.scope_column(variable).ok_or(())?;
13629            let placeholders: Vec<String> = values
13630                .iter()
13631                .map(|v| {
13632                    params.push(SqlValue::Text(v.clone()));
13633                    "?".to_owned()
13634                })
13635                .collect();
13636            clauses.push(format!(
13637                "{} IN ({})",
13638                quote_ident(column),
13639                placeholders.join(", ")
13640            ));
13641        }
13642        let sql = format!(
13643            "DELETE FROM {} WHERE {}",
13644            base_table(table_name),
13645            clauses.join(" AND ")
13646        );
13647        self.overlay_dirty.table(table_name);
13648        self.conn
13649            .execute(&sql, rusqlite::params_from_iter(&params))
13650            .map_err(|_| ())?;
13651        self.conn
13652            .execute(
13653                &format!(
13654                    "DELETE FROM {} WHERE {}",
13655                    visible_table(table_name),
13656                    clauses.join(" AND ")
13657                ),
13658                rusqlite::params_from_iter(&params),
13659            )
13660            .map_err(|_| ())?;
13661        Ok(())
13662    }
13663
13664    /// §3.3: drop pending commits whose upserts provably land in the
13665    /// revoked effective scopes — whole-commit, never per-operation.
13666    fn drop_doomed_outbox(
13667        &mut self,
13668        table_name: &str,
13669        effective: &[(String, Vec<String>)],
13670    ) -> Result<bool, String> {
13671        if effective.is_empty() {
13672            return Ok(false);
13673        }
13674        let Some(table) = self.schema.table(table_name).cloned() else {
13675            return Ok(false);
13676        };
13677        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
13678        for (variable, values) in effective {
13679            match table.scope_column(variable) {
13680                Some(column) => mappings.push((column, values)),
13681                None => return Ok(false), // not provable without a mapping
13682            }
13683        }
13684        let doomed: Vec<OutboxCommit> = self
13685            .outbox
13686            .iter()
13687            .filter(|commit| {
13688                commit.ops.iter().any(|op| {
13689                    op.upsert
13690                        && op.table == table_name
13691                        && op.values.as_ref().is_some_and(|values| {
13692                            mappings
13693                                .iter()
13694                                .all(|(column, allowed)| match values.get(*column) {
13695                                    Some(Value::String(s)) => allowed.contains(s),
13696                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
13697                                    _ => false,
13698                                })
13699                        })
13700                })
13701            })
13702            .cloned()
13703            .collect();
13704        if doomed.is_empty() {
13705            return Ok(false);
13706        }
13707        let mut rejections = Vec::new();
13708        for commit in &doomed {
13709            let results = commit
13710                .ops
13711                .iter()
13712                .enumerate()
13713                .map(|(op_index, operation)| {
13714                    let rejection = RejectionRecord {
13715                        client_commit_id: commit.client_commit_id.clone(),
13716                        op_index: op_index as i32,
13717                        code: "sync.scope_revoked".to_owned(),
13718                        message: "the commit was dropped because its effective scope was revoked"
13719                            .to_owned(),
13720                        retryable: false,
13721                        details: None,
13722                        operation: Some(CommitOperation::from(operation)),
13723                    };
13724                    rejections.push(rejection.clone());
13725                    CommitOperationOutcome::Error { rejection }
13726                })
13727                .collect::<Vec<_>>();
13728            self.persist_commit_outcome(
13729                &commit.client_commit_id,
13730                CommitOutcomeStatus::Rejected,
13731                &results,
13732                Some(&commit.ops),
13733            )?;
13734            self.delete_outbox_persisted(&commit.client_commit_id)?;
13735        }
13736        self.prune_commit_outcomes()?;
13737        let doomed_ids = doomed
13738            .iter()
13739            .map(|commit| commit.client_commit_id.as_str())
13740            .collect::<BTreeSet<_>>();
13741        self.outbox
13742            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
13743        self.rejections.extend(rejections);
13744        Ok(true)
13745    }
13746
13747    // -- blobs (§5.9) ----------------------------------------------------------------
13748
13749    /// §5.9.7: hash bytes into the content address, cache them, queue the
13750    /// upload (flushed before the next push, B4). Returns the canonical
13751    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
13752    /// column value.
13753    pub fn upload_blob(
13754        &mut self,
13755        bytes: &[u8],
13756        media_type: Option<String>,
13757        name: Option<String>,
13758    ) -> Result<Value, String> {
13759        let blob_id = blob_id_for(bytes);
13760        let now = self.clock_now_ms();
13761        let tx = self
13762            .conn
13763            .transaction()
13764            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13765        tx.execute(
13766            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
13767             ON CONFLICT(blob_id) DO NOTHING",
13768            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
13769        )
13770        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13771        tx.execute(
13772            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
13773            rusqlite::params![blob_id, media_type, now],
13774        )
13775        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13776        tx.commit()
13777            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13778        self.enforce_blob_cache_cap();
13779        let mut obj = Map::new();
13780        obj.insert("blobId".to_owned(), Value::from(blob_id));
13781        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
13782        if let Some(mt) = media_type {
13783            obj.insert("mediaType".to_owned(), Value::from(mt));
13784        }
13785        if let Some(n) = name {
13786            obj.insert("name".to_owned(), Value::from(n));
13787        }
13788        Ok(Value::Object(obj))
13789    }
13790
13791    /// §5.9.7: resolve an owned blob body without driver encoding. A
13792    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
13793    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
13794    pub fn fetch_blob_bytes(
13795        &mut self,
13796        transport: &mut dyn Transport,
13797        blob_id_or_ref: &str,
13798    ) -> Result<FetchedBlob, (String, String)> {
13799        let simple = |m: String| ("client.failed".to_owned(), m);
13800        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
13801            blob_id_or_ref.to_owned()
13802        } else {
13803            let value: Value = serde_json::from_str(blob_id_or_ref)
13804                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
13805            value
13806                .get("blobId")
13807                .and_then(Value::as_str)
13808                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
13809                .to_owned()
13810        };
13811        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
13812            return Ok(cached);
13813        }
13814        if !self.transport_enabled {
13815            return Err(("sync.offline".into(), "transport gate is closed".into()));
13816        }
13817        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
13818        // blob.not_found) verbatim so the harness can assert on it. The
13819        // authorized endpoint serves bytes inline OR (always-issue, presign
13820        // configured) a signed url the client fetches directly — no host auth,
13821        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
13822        #[cfg(feature = "bench-internals")]
13823        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
13824        let bytes = match transport
13825            .blob_download(&blob_id)
13826            .map_err(|e| (e.code, e.message))?
13827        {
13828            BlobDownload::Bytes(bytes) => bytes,
13829            BlobDownload::Url {
13830                url,
13831                url_expires_at_ms,
13832            } => {
13833                // §5.9.5: MUST NOT start a fetch at/past expiry.
13834                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
13835                    return Err((
13836                        "sync.segment_expired".to_owned(),
13837                        format!(
13838                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
13839                        ),
13840                    ));
13841                }
13842                transport
13843                    .fetch_blob_url(&url)
13844                    .map_err(|e| (e.code, e.message))?
13845            }
13846        };
13847        #[cfg(feature = "bench-internals")]
13848        drop(download_phase);
13849        #[cfg(feature = "bench-internals")]
13850        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
13851        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
13852        if blob_id_for(&bytes) != blob_id {
13853            return Err(simple(format!(
13854                "blob content address mismatch for {blob_id}"
13855            )));
13856        }
13857        #[cfg(feature = "bench-internals")]
13858        drop(validate_phase);
13859        #[cfg(feature = "bench-internals")]
13860        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
13861        let now = self.clock_now_ms();
13862        self.conn
13863            .execute(
13864                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
13865                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
13866            )
13867            .map_err(|e| simple(e.to_string()))?;
13868        self.conn
13869            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
13870            .map_err(|e| simple(e.to_string()))?;
13871        self.enforce_blob_cache_cap();
13872        #[cfg(feature = "bench-internals")]
13873        drop(insert_phase);
13874        self.get_cached_blob_bytes(&blob_id)
13875            .map_err(simple)?
13876            .ok_or_else(|| simple("blob cache write failed".to_owned()))
13877    }
13878
13879    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
13880        #[cfg(feature = "bench-internals")]
13881        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
13882        let mut stmt = self
13883            .conn
13884            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
13885            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13886        let mut rows = stmt
13887            .query(rusqlite::params![blob_id])
13888            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13889        if let Some(row) = rows
13890            .next()
13891            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13892        {
13893            let bytes: Vec<u8> = row
13894                .get(0)
13895                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13896            let byte_length: i64 = row
13897                .get(1)
13898                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13899            let media_type: Option<String> = row
13900                .get(2)
13901                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13902            return Ok(Some(FetchedBlob {
13903                blob_id: blob_id.to_owned(),
13904                byte_length,
13905                bytes,
13906                media_type,
13907            }));
13908        }
13909        Ok(None)
13910    }
13911
13912    /// §5.9.7 B4: upload every queued blob before push.
13913    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
13914        let pending: Vec<(String, Option<String>)> = {
13915            let mut stmt = self
13916                .conn
13917                .prepare(
13918                    "SELECT blob_id, media_type FROM (
13919                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
13920                      UNION ALL
13921                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
13922                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
13923                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
13924                    ) ORDER BY created_at_ms, blob_id",
13925                )
13926                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13927            let rows = stmt
13928                .query_map([], |row| {
13929                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
13930                })
13931                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13932            rows.collect::<Result<_, _>>()
13933                .map_err(|error| match error {
13934                    rusqlite::Error::InvalidColumnType(..)
13935                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
13936                        "sync.local_corrupt",
13937                        "Pending blob upload metadata is invalid",
13938                    ),
13939                    _ => TransportError::new("client.failed", error.to_string()),
13940                })?
13941        };
13942        let mut uploads = Vec::new();
13943        for (blob_id, media_type) in pending {
13944            let bytes = (|| {
13945                let stored: Option<(SqlValue, SqlValue)> = self
13946                    .conn
13947                    .query_row(
13948                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
13949                        rusqlite::params![blob_id],
13950                        |row| Ok((row.get(0)?, row.get(1)?)),
13951                    )
13952                    .optional()
13953                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13954                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
13955                    return Err(TransportError::new(
13956                        "sync.local_corrupt",
13957                        "Pending blob upload body is missing or corrupt",
13958                    ));
13959                };
13960                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
13961                    return Err(TransportError::new(
13962                        "sync.local_corrupt",
13963                        "Pending blob upload body is missing or corrupt",
13964                    ));
13965                }
13966                Ok(bytes)
13967            })();
13968            let failed = bytes.is_err();
13969            uploads.push(crate::round::PendingUpload {
13970                id: blob_id,
13971                bytes,
13972                media_type,
13973            });
13974            if failed {
13975                break;
13976            }
13977        }
13978        Ok(uploads)
13979    }
13980
13981    #[cfg(test)]
13982    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
13983        for upload in self.pending_blob_uploads()? {
13984            self.upload_one(
13985                transport,
13986                &upload.id,
13987                &upload.bytes?,
13988                upload.media_type.as_deref(),
13989            )?;
13990            self.conn
13991                .execute(
13992                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
13993                    rusqlite::params![upload.id],
13994                )
13995                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13996        }
13997        Ok(())
13998    }
13999
14000    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
14001    /// grant when the transport supports it, else streaming through the direct
14002    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
14003    /// PUTs direct with no host auth; on a grant PUT failure the client streams
14004    /// through the direct endpoint — a *different, host-authenticated
14005    /// capability*, not a fall-through of the grant's authority.
14006    #[cfg(test)]
14007    fn upload_one(
14008        &self,
14009        transport: &mut dyn Transport,
14010        blob_id: &str,
14011        bytes: &[u8],
14012        media_type: Option<&str>,
14013    ) -> Result<(), TransportError> {
14014        crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
14015    }
14016
14017    fn visible_blob_ids_sql(&self) -> String {
14018        let mut selects = Vec::new();
14019        for table in &self.schema.tables {
14020            for column in table
14021                .columns
14022                .iter()
14023                .filter(|column| column.ty == ColumnType::BlobRef)
14024            {
14025                let identifier = quote_ident(&column.name);
14026                selects.push(format!(
14027                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
14028                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
14029                     AND json_type({identifier}, '$.blobId') = 'text'",
14030                    quote_ident(&table.name),
14031                ));
14032            }
14033        }
14034        if selects.is_empty() {
14035            "SELECT NULL AS blob_id WHERE 0".to_owned()
14036        } else {
14037            selects.join(" UNION ")
14038        }
14039    }
14040
14041    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
14042    /// staged uploads pin bodies directly; no derived refcount is stored.
14043    fn enforce_blob_cache_cap(&self) {
14044        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
14045            return;
14046        };
14047        let mut total: i64 = self
14048            .conn
14049            .query_row(
14050                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
14051                [],
14052                |row| row.get(0),
14053            )
14054            .unwrap_or(0);
14055        if total <= max_bytes {
14056            return;
14057        }
14058        let query = format!(
14059            "SELECT blob_id, byte_length FROM _syncular_blobs
14060             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
14061               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
14062               AND blob_id NOT IN ({})
14063             ORDER BY created_at_ms ASC, blob_id ASC",
14064            self.visible_blob_ids_sql(),
14065        );
14066        let candidates: Vec<(String, i64)> = {
14067            let Ok(mut stmt) = self.conn.prepare(&query) else {
14068                return;
14069            };
14070            let Ok(rows) = stmt.query_map([], |row| {
14071                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
14072            }) else {
14073                return;
14074            };
14075            rows.filter_map(Result::ok).collect()
14076        };
14077        for (blob_id, byte_length) in candidates {
14078            if total <= max_bytes {
14079                break;
14080            }
14081            let _ = self.conn.execute(
14082                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
14083                rusqlite::params![blob_id],
14084            );
14085            total -= byte_length;
14086        }
14087    }
14088
14089    fn delete_unreferenced_cached_blobs(&mut self) {
14090        #[cfg(feature = "bench-internals")]
14091        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
14092        if !self.schema_has_blobs() {
14093            return;
14094        }
14095        self.rebuild_overlay_if_dirty();
14096        let _ = self.conn.execute(
14097            &format!(
14098                "DELETE FROM _syncular_blobs
14099                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
14100                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
14101                   AND blob_id NOT IN ({})",
14102                self.visible_blob_ids_sql(),
14103            ),
14104            [],
14105        );
14106    }
14107
14108    // -- local row storage ----------------------------------------------------------
14109
14110    /// The cached per-table primary-key upsert SQL for `full_table` (see
14111    /// the `insert_sql` field: built once, reused per row).
14112    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
14113        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
14114            return sql.clone();
14115        }
14116        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
14117        columns.push(quote_ident("_syncular_version"));
14118        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
14119        let primary_key = quote_ident(&table.primary_key);
14120        let updates = columns
14121            .iter()
14122            .filter(|column| **column != primary_key)
14123            .map(|column| format!("{column}=excluded.{column}"))
14124            .collect::<Vec<_>>()
14125            .join(", ");
14126        let sql = format!(
14127            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
14128            columns.join(", "),
14129            placeholders.join(", ")
14130        );
14131        self.insert_sql
14132            .borrow_mut()
14133            .insert(full_table.to_owned(), sql.clone());
14134        sql
14135    }
14136
14137    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
14138        self.overlay_dirty.table(table_name);
14139        self.write_row(&base_table(table_name), table_name, row, version)
14140    }
14141
14142    fn write_row(
14143        &self,
14144        full_table: &str,
14145        table_name: &str,
14146        row: &Row,
14147        version: i64,
14148    ) -> Result<(), String> {
14149        #[cfg(feature = "bench-internals")]
14150        let _phase = self.benchmark_phases.start(Phase::RowWrite);
14151        let table = self
14152            .schema
14153            .table(table_name)
14154            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
14155        let sql = self.insert_row_sql(full_table, table);
14156        let mut stmt = self
14157            .conn
14158            .prepare_cached(&sql)
14159            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14160        let params = row
14161            .iter()
14162            .map(RowParam::Cell)
14163            .chain(std::iter::once(RowParam::Version(version)));
14164        stmt.execute(rusqlite::params_from_iter(params))
14165            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14166        Ok(())
14167    }
14168
14169    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
14170        let table = self
14171            .schema
14172            .table(table_name)
14173            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
14174        self.overlay_dirty.table(table_name);
14175        let sql = format!(
14176            "DELETE FROM {} WHERE {}",
14177            base_table(table_name),
14178            row_id_predicate(table)
14179        );
14180        let mut stmt = self
14181            .conn
14182            .prepare_cached(&sql)
14183            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14184        stmt.execute(rusqlite::params![row_id])
14185            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14186        Ok(())
14187    }
14188
14189    fn has_acknowledged_rows(&self) -> bool {
14190        self.conn
14191            .query_row(
14192                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
14193                [],
14194                |row| row.get(0),
14195            )
14196            .expect("read acknowledged intent")
14197    }
14198
14199    fn acknowledged_operations(
14200        &self,
14201        rows: Option<&HashSet<(&str, &str)>>,
14202        tables: Option<&BTreeSet<&str>>,
14203    ) -> Result<Vec<OutboxOp>, String> {
14204        let keys = rows.map(|rows| {
14205            serde_json::to_string(
14206                &rows
14207                    .iter()
14208                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
14209                    .collect::<Vec<_>>(),
14210            )
14211            .expect("row keys")
14212        });
14213        let mut statement = self
14214            .conn
14215            .prepare("SELECT op_json,intent_json FROM _syncular_acked_rows WHERE (?1 IS NULL OR json_array(tbl,id) IN(SELECT value FROM json_each(?1))) AND (?2 IS NULL OR tbl IN(SELECT value FROM json_each(?2))) ORDER BY commit_seq,idx")
14216            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14217        let rows = statement
14218            .query_map(
14219                rusqlite::params![
14220                    keys,
14221                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
14222                ],
14223                |row| {
14224                    #[cfg(test)]
14225                    self.acknowledged_replay_count
14226                        .set(self.acknowledged_replay_count.get() + 1);
14227                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
14228                },
14229            )
14230            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14231        rows.map(|row| {
14232            let (op, intent) =
14233                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14234            let op: CommitOperation =
14235                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
14236            let table = self
14237                .schema
14238                .table(&op.table)
14239                .ok_or_else(|| "sync.unknown_table".to_owned())?;
14240            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id).is_none() {
14241                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
14242            } else {
14243                op.values
14244            };
14245            Ok(OutboxOp {
14246                table: op.table,
14247                row_id: op.row_id,
14248                upsert: op.op == "upsert",
14249                base_version: op.base_version,
14250                values,
14251            })
14252        })
14253        .collect()
14254    }
14255
14256    fn drop_acknowledged_rows(
14257        &self,
14258        matches: impl Fn(&str, &Map<String, Value>) -> bool,
14259    ) -> Result<Vec<OutboxCommit>, String> {
14260        let mut statement = self
14261            .conn
14262            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
14263            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14264        let rows = statement
14265            .query_map([], |row| {
14266                Ok((
14267                    row.get::<_, String>(0)?,
14268                    row.get::<_, String>(1)?,
14269                    row.get::<_, String>(2)?,
14270                    row.get::<_, String>(3)?,
14271                ))
14272            })
14273            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14274        let mut doomed = HashSet::new();
14275        for row in rows {
14276            let (id, table, intent, row_id) =
14277                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14278            let values =
14279                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
14280            let base_matches = self
14281                .schema
14282                .table(&table)
14283                .and_then(|schema| self.stored_row(schema, &row_id, true))
14284                .is_some_and(|(base, _)| {
14285                    let schema = self.schema.table(&table).expect("acknowledged table");
14286                    let values = schema
14287                        .columns
14288                        .iter()
14289                        .zip(base.iter())
14290                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14291                        .collect();
14292                    matches(&table, &values)
14293                });
14294            if matches(&table, &values) || base_matches {
14295                doomed.insert(id);
14296            }
14297        }
14298        let mut dropped = Vec::new();
14299        for id in doomed {
14300            let mut statement = self
14301                .conn
14302                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
14303                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14304            let operations = statement
14305                .query_map([&id], |row| row.get::<_, String>(0))
14306                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14307            let mut ops = Vec::new();
14308            for operation in operations {
14309                let json = operation
14310                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14311                let op: CommitOperation =
14312                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
14313                self.overlay_dirty.table(&op.table);
14314                ops.push(OutboxOp {
14315                    table: op.table,
14316                    row_id: op.row_id,
14317                    upsert: op.op == "upsert",
14318                    base_version: op.base_version,
14319                    values: op.values,
14320                });
14321            }
14322            dropped.push(OutboxCommit {
14323                client_commit_id: id.clone(),
14324                ops,
14325            });
14326            self.conn
14327                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
14328                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14329        }
14330        Ok(dropped)
14331    }
14332
14333    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
14334    /// the outbox changed since the last rebuild (the no-op sync round).
14335    fn rebuild_overlay_if_dirty(&mut self) {
14336        if self.overlay_dirty.get() {
14337            self.rebuild_overlay();
14338        }
14339    }
14340
14341    /// §7.1: local reads see outbox state applied optimistically — rebuild
14342    /// every visible table as (base server state) + (pending outbox replay
14343    /// on top). Optimistic rows carry version `-1`.
14344    fn rebuild_overlay(&mut self) {
14345        #[cfg(feature = "bench-internals")]
14346        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
14347        #[cfg(test)]
14348        self.overlay_rebuild_count
14349            .set(self.overlay_rebuild_count.get() + 1);
14350        let dirty = self.overlay_dirty.snapshot();
14351        let tables: Vec<_> = self
14352            .schema
14353            .tables
14354            .iter()
14355            .filter(|table| {
14356                dirty
14357                    .as_ref()
14358                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
14359            })
14360            .cloned()
14361            .collect();
14362        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
14363        self.exec("SAVEPOINT syncular_overlay");
14364        for table in &tables {
14365            for index in &table.fts_indexes {
14366                let _ = self.drop_fts_triggers(index);
14367            }
14368            let visible = visible_table(&table.name);
14369            let base = base_table(&table.name);
14370            self.exec(&format!("DELETE FROM {visible}"));
14371            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
14372        }
14373        let acknowledged = self
14374            .acknowledged_operations(None, Some(&names))
14375            .expect("decode acknowledged intent");
14376        self.apply_outbox_ops(
14377            acknowledged
14378                .iter()
14379                .filter(|op| names.contains(op.table.as_str())),
14380        );
14381        for (commit, initial) in &self.failed_commits {
14382            for operation in &commit.ops {
14383                if !names.contains(operation.table.as_str()) || !operation.upsert {
14384                    continue;
14385                }
14386                if let Some(table) = self.schema.table(&operation.table) {
14387                    if self.visible_row(table, &operation.row_id).is_none()
14388                        && operation.values.as_ref().is_some_and(|values| {
14389                            table
14390                                .columns
14391                                .iter()
14392                                .all(|column| values.contains_key(&column.name))
14393                        })
14394                    {
14395                        let key = serde_json::to_string(&(
14396                            operation.table.clone(),
14397                            operation.row_id.clone(),
14398                        ))
14399                        .expect("row key serialization");
14400                        if let Some(Value::Object(values)) = initial.get(&key) {
14401                            self.apply_outbox_ops(&[OutboxOp {
14402                                values: Some(values.clone()),
14403                                ..operation.clone()
14404                            }]);
14405                        }
14406                    }
14407                }
14408            }
14409            self.apply_outbox_ops(
14410                commit
14411                    .ops
14412                    .iter()
14413                    .filter(|op| names.contains(op.table.as_str())),
14414            );
14415        }
14416        for commit in &self.outbox {
14417            self.apply_outbox_ops(
14418                commit
14419                    .ops
14420                    .iter()
14421                    .filter(|op| names.contains(op.table.as_str())),
14422            );
14423        }
14424        for table in &tables {
14425            for index in &table.fts_indexes {
14426                let _ = self.rebuild_fts_projection(table, index);
14427                let _ = self.create_fts_triggers(table, index);
14428            }
14429        }
14430        self.exec("RELEASE syncular_overlay");
14431        self.overlay_dirty.set(false);
14432    }
14433
14434    /// §7.1 replay: every operation is applied over the current visible
14435    /// state. An operation that no longer applies (a server row now holds its
14436    /// unique value) stays pending and invisible until a later replay admits
14437    /// it or the server answers its push.
14438    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
14439        #[cfg(feature = "bench-internals")]
14440        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
14441        for op in ops {
14442            #[cfg(feature = "bench-internals")]
14443            if let Some(phase) = &mut phase {
14444                phase.unit();
14445            }
14446            if op.upsert {
14447                if let (Some(table), Some(values)) = (
14448                    self.schema
14449                        .table(&op.table)
14450                        .filter(|table| table.indexes.iter().any(|index| index.unique)),
14451                    op.values.as_ref(),
14452                ) {
14453                    let mut intended: Map<String, Value> = self
14454                        .visible_row(table, &op.row_id)
14455                        .map(|(row, _)| {
14456                            table
14457                                .columns
14458                                .iter()
14459                                .zip(&row)
14460                                .map(|(column, value)| {
14461                                    (column.name.clone(), column_value_to_json(value))
14462                                })
14463                                .collect()
14464                        })
14465                        .unwrap_or_default();
14466                    intended.extend(values.clone());
14467                    if !self
14468                        .unique_conflicts(table, &intended, false)
14469                        .expect("overlay unique-key read failed")
14470                        .is_empty()
14471                    {
14472                        continue;
14473                    }
14474                }
14475            }
14476            let _ = self.apply_outbox_op(op);
14477        }
14478    }
14479
14480    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
14481        let Some(table) = self.schema.table(&op.table) else {
14482            return Ok(());
14483        };
14484        let visible = visible_table(&table.name);
14485        if !op.upsert {
14486            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
14487            self.conn
14488                .prepare_cached(&sql)
14489                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
14490                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14491            return Ok(());
14492        }
14493        let Some(values) = op.values.as_ref() else {
14494            return Ok(());
14495        };
14496        // §7.1: the overlay applies the operation's PRESENT columns
14497        // over the current local row. A partial operation over an
14498        // absent local row leaves it absent — the server answers it
14499        // with sync.row_deleted or sync.row_missing (§6.2).
14500        let full = table
14501            .columns
14502            .iter()
14503            .all(|column| values.contains_key(&column.name));
14504        let local = self.visible_row(table, &op.row_id);
14505        if local.is_none() && !full {
14506            return Ok(());
14507        }
14508        let mut row: Row = Vec::with_capacity(table.columns.len());
14509        for (index, column) in table.columns.iter().enumerate() {
14510            let incoming = match values.get(&column.name) {
14511                Some(raw) => json_to_column_value(column, Some(raw)),
14512                None => Ok(local
14513                    .as_ref()
14514                    .and_then(|(values, _)| values.get(index).cloned())
14515                    .flatten()),
14516            };
14517            match incoming {
14518                Ok(value) => row.push(value),
14519                Err(_) => return Ok(()),
14520            }
14521        }
14522        let version = local.as_ref().map_or(-1, |(_, version)| *version);
14523        self.write_row(&visible, &table.name, &row, version)
14524            .map_err(|error| {
14525                if error.starts_with("UNIQUE constraint failed:") {
14526                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
14527                } else {
14528                    error
14529                }
14530            })
14531    }
14532
14533    /// The visible (optimistic) row's values plus version, or `None` when the
14534    /// row is absent from the overlay.
14535    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
14536        self.stored_row(table, row_id, false)
14537    }
14538
14539    fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
14540        let sql = format!(
14541            "SELECT * FROM {} WHERE {}",
14542            if base {
14543                base_table(&table.name)
14544            } else {
14545                visible_table(&table.name)
14546            },
14547            row_id_predicate(table)
14548        );
14549        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
14550        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
14551        let row = rows.next().ok()??;
14552        let mut values = Vec::with_capacity(table.columns.len());
14553        for (index, column) in table.columns.iter().enumerate() {
14554            let raw = row.get_ref(index).ok()?;
14555            let json = sql_ref_to_json(column, raw);
14556            values.push(json_to_column_value(column, Some(&json)).ok()?);
14557        }
14558        let version: i64 = row.get(table.columns.len()).ok()?;
14559        Some((values, version))
14560    }
14561
14562    fn exec(&self, sql: &str) {
14563        let _ = self.conn.execute_batch(sql);
14564    }
14565
14566    // -- realtime (§8) ---------------------------------------------------------------
14567
14568    /// §8.8: the configured realtime policy.
14569    #[must_use]
14570    pub fn realtime_policy(&self) -> RealtimePolicy {
14571        self.realtime_policy
14572    }
14573
14574    /// §8.8: the explicit realtime availability state.
14575    #[must_use]
14576    pub fn realtime_state(&self) -> RealtimeState {
14577        self.realtime_state
14578    }
14579
14580    /// §8.8: set the realtime policy. Refused while the socket is connected:
14581    /// a policy change never silently re-routes an ownership state.
14582    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
14583        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
14584            return Err(
14585                "sync.invalid_request: disconnect realtime before changing the realtime policy"
14586                    .to_owned(),
14587            );
14588        }
14589        self.realtime_policy = policy;
14590        match policy {
14591            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
14592            RealtimePolicy::Required | RealtimePolicy::Optional
14593                if self.realtime_state == RealtimeState::Disabled =>
14594            {
14595                self.set_realtime_state(RealtimeState::Disconnected, None);
14596            }
14597            RealtimePolicy::Required | RealtimePolicy::Optional => {}
14598        }
14599        Ok(())
14600    }
14601
14602    /// Record an availability transition, keeping the reason code bounded and
14603    /// code-like (§7.6) and clearing it where it cannot apply.
14604    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
14605        self.realtime_state = state;
14606        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
14607        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
14608            self.realtime_reason_code = None;
14609            self.realtime_retry_delay_ms = None;
14610        }
14611    }
14612
14613    #[must_use]
14614    fn realtime_connected(&self) -> bool {
14615        self.realtime_state == RealtimeState::Connected
14616    }
14617
14618    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
14619        if !self.transport_enabled {
14620            return Err("sync.offline: transport gate is closed".into());
14621        }
14622        if self.realtime_policy == RealtimePolicy::Off {
14623            return Err(
14624                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
14625            );
14626        }
14627        if self.realtime_connected() {
14628            return Ok(());
14629        }
14630        self.realtime_state = RealtimeState::Connecting;
14631        match transport.realtime_connect_for_client(&self.client_id) {
14632            Ok(()) => {
14633                self.set_realtime_state(RealtimeState::Connected, None);
14634                Ok(())
14635            }
14636            Err(error) => {
14637                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
14638                Err(format!("{}: {}", error.code, error.message))
14639            }
14640        }
14641    }
14642
14643    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
14644        self.cancel_sync_round();
14645        // A deliberately disconnected or policy-disabled client has nothing
14646        // to release; a `lost` socket still does (the host owns the handle).
14647        if self.realtime_state == RealtimeState::Disconnected
14648            || self.realtime_state == RealtimeState::Disabled
14649        {
14650            return;
14651        }
14652        let _ = transport.realtime_close();
14653        self.set_realtime_state(
14654            if self.realtime_policy == RealtimePolicy::Off {
14655                RealtimeState::Disabled
14656            } else {
14657                RealtimeState::Disconnected
14658            },
14659            None,
14660        );
14661        self.presence.clear(); // §8.6.1: presence is per-connection
14662    }
14663
14664    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
14665    /// document for `scope_key`. Requires a live socket; the document is
14666    /// ephemeral (lost on disconnect). Authorization is the connection's
14667    /// registration (§8.6.3) — an unheld key is rejected loudly by the
14668    /// server with `presence.forbidden`.
14669    pub fn set_presence(
14670        &mut self,
14671        transport: &mut dyn Transport,
14672        scope_key: &str,
14673        doc: Option<&Value>,
14674    ) -> Result<(), String> {
14675        if !self.transport_enabled {
14676            return Err("sync.offline: transport gate is closed".into());
14677        }
14678        if !self.realtime_connected() {
14679            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
14680        }
14681        let text = encode_presence_publish(scope_key, doc);
14682        transport
14683            .realtime_send(&text)
14684            .map_err(|e| format!("{}: {}", e.code, e.message))
14685    }
14686
14687    /// §8.6: the peers currently present on a scope key (ephemeral).
14688    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
14689        self.presence
14690            .get(scope_key)
14691            .map(|peers| peers.values().cloned().collect())
14692            .unwrap_or_default()
14693    }
14694
14695    /// §8.6 apply an inbound presence fanout to the local map.
14696    fn apply_presence(
14697        &mut self,
14698        scope_key: String,
14699        kind: Option<PresenceKind>,
14700        actor_id: Option<String>,
14701        client_id: Option<String>,
14702        doc: Option<Value>,
14703        error: Option<String>,
14704    ) {
14705        // The publisher-directed error variant is out-of-band; nothing to
14706        // record in the peer map.
14707        if error.is_some() {
14708            return;
14709        }
14710        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
14711            return;
14712        };
14713        let peer_key = format!("{actor_id} {client_id}");
14714        match kind {
14715            PresenceKind::Leave => {
14716                if let Some(peers) = self.presence.get_mut(&scope_key) {
14717                    peers.remove(&peer_key);
14718                    if peers.is_empty() {
14719                        self.presence.remove(&scope_key);
14720                    }
14721                }
14722            }
14723            _ => {
14724                let doc = match doc {
14725                    Some(Value::Object(_)) => doc.unwrap(),
14726                    _ => return,
14727                };
14728                self.presence.entry(scope_key).or_default().insert(
14729                    peer_key,
14730                    PresencePeer {
14731                        actor_id,
14732                        client_id,
14733                        doc,
14734                    },
14735                );
14736            }
14737        }
14738    }
14739
14740    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
14741    pub fn on_realtime_text(&mut self, text: &str) {
14742        match parse_control(text) {
14743            Ok(ControlMessage::Hello { requires_sync, .. }) => {
14744                if requires_sync {
14745                    // §8.1: pull before trusting the socket for continuity.
14746                    self.set_sync_needed(true, true);
14747                }
14748            }
14749            Ok(ControlMessage::Presence {
14750                scope_key,
14751                kind,
14752                actor_id,
14753                client_id,
14754                doc,
14755                error,
14756                ..
14757            }) => {
14758                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
14759            }
14760            Ok(ControlMessage::Wake { .. }) => {
14761                // §8.3: any wake-up means "run a pull soon", never data.
14762                self.set_sync_needed(true, true);
14763            }
14764            _ => {}
14765        }
14766    }
14767
14768    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
14769    /// a pull response per section; an unapplied delta is a wake-up.
14770    /// Mailbox hosts send durable acknowledgements on their I/O executor.
14771    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
14772        let mut transport = crate::round::DownloadResults::default();
14773        self.on_realtime_binary(&mut transport, bytes);
14774        transport.controls
14775    }
14776
14777    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
14778        if self.stopped || !self.transport_enabled {
14779            return;
14780        }
14781        #[cfg(feature = "bench-internals")]
14782        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
14783        let message = match decode_message(bytes) {
14784            Ok(m) if m.msg_kind == MsgKind::Response => m,
14785            _ => {
14786                self.set_sync_needed(true, true);
14787                return;
14788            }
14789        };
14790        #[cfg(feature = "bench-internals")]
14791        drop(decode_phase);
14792        let mut frames = message.frames.into_iter();
14793        let mut applied_cursor: Option<i64> = None;
14794        let mut any_covered = false;
14795        let mut dropped = false;
14796        while let Some(frame) = frames.next() {
14797            let Frame::SubStart {
14798                id,
14799                status,
14800                effective_scopes,
14801                ..
14802            } = frame
14803            else {
14804                continue;
14805            };
14806            let mut body = Vec::new();
14807            let mut next_cursor: Option<i64> = None;
14808            for inner in frames.by_ref() {
14809                match inner {
14810                    Frame::SubEnd {
14811                        next_cursor: nc, ..
14812                    } => {
14813                        next_cursor = Some(nc);
14814                        break;
14815                    }
14816                    Frame::Unknown { .. } => {}
14817                    other => body.push(other),
14818                }
14819            }
14820            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
14821                dropped = true;
14822                continue;
14823            };
14824            let sub = &self.subs[sub_index];
14825            // §8.2: only locally active, not mid-bootstrap subscriptions
14826            // apply; skipped sections are repaired by the next pull.
14827            if status != SubStatus::Active
14828                || sub.state != SubState::Active
14829                || sub.bootstrap_state.is_some()
14830                || !sub.synced_once
14831            {
14832                dropped = true;
14833                continue;
14834            }
14835            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
14836                // Idempotent redelivery of an already-covered window.
14837                any_covered = true;
14838                continue;
14839            }
14840            let previous_effective = self.subs[sub_index].effective.clone();
14841            let previous_cursor = self.subs[sub_index].cursor;
14842            self.subs[sub_index].effective = Some(effective_scopes);
14843            let mut failed = false;
14844            for inner in body {
14845                match inner {
14846                    Frame::Commit {
14847                        tables,
14848                        changes,
14849                        commit_seq,
14850                        ..
14851                    } => {
14852                        if self
14853                            .apply_commit_frame(&tables, &changes, Some(commit_seq))
14854                            .is_err()
14855                        {
14856                            failed = true;
14857                            break;
14858                        }
14859                    }
14860                    _ => {
14861                        failed = true;
14862                        break;
14863                    }
14864                }
14865            }
14866            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
14867                self.apply_sub_end(sub_index, cursor, None)
14868                    .ok()
14869                    .map(|()| cursor)
14870            });
14871            let Some(next_cursor) = committed else {
14872                self.subs[sub_index].effective = previous_effective;
14873                self.subs[sub_index].cursor = previous_cursor;
14874                dropped = true;
14875                continue;
14876            };
14877            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
14878        }
14879        if let Some(cursor) = applied_cursor {
14880            // §8.2 ack point: the highest applied SUB_END.nextCursor.
14881            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14882            let _ = transport.realtime_send(&ack);
14883        } else if !any_covered || dropped {
14884            // §8.2: a delta not applied at all is treated as a wake-up.
14885            self.set_sync_needed(true, true);
14886        }
14887    }
14888
14889    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
14890    /// cursor across active, non-bootstrapping subscriptions that have
14891    /// synced at least once. No such subscription, no ack.
14892    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
14893        if !self.realtime_connected() {
14894            return;
14895        }
14896        let floor = self
14897            .subs
14898            .iter()
14899            .filter(|s| {
14900                s.state == SubState::Active
14901                    && s.bootstrap_state.is_none()
14902                    && s.synced_once
14903                    && s.cursor >= 0
14904            })
14905            .map(|s| s.cursor)
14906            .min();
14907        if let Some(cursor) = floor {
14908            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14909            let _ = transport.realtime_send(&ack);
14910        }
14911    }
14912}
14913
14914#[cfg(test)]
14915mod previous_version_wiring_tests {
14916    use super::*;
14917    use serde_json::{json, Value};
14918
14919    fn previous_version_schema(version: i32) -> Value {
14920        json!({
14921            "version": version,
14922            "tables": [{
14923                "name": "tasks",
14924                "primaryKey": "id",
14925                "columns": [
14926                    { "name": "id", "type": "string", "nullable": false },
14927                    { "name": "note", "type": "string", "nullable": true }
14928                ],
14929                "scopes": []
14930            }]
14931        })
14932    }
14933
14934    fn previous_version_limits() -> crate::api::ClientLimits {
14935        crate::api::ClientLimits {
14936            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
14937                enabled: true,
14938                ..Default::default()
14939            }),
14940            ..Default::default()
14941        }
14942    }
14943
14944    /// The configuration the live client carries, for a direct capture call.
14945    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
14946        previous_version_limits()
14947            .previous_version_context
14948            .expect("configured")
14949    }
14950
14951    /// Capture the container the way a bump would, so a read test starts from a
14952    /// container this client's own state will then decide about.
14953    fn recapture_previous_version(client: &SyncClient) {
14954        let replica_path = client
14955            .previous_version_replica_path()
14956            .expect("file-backed replica");
14957        let outcome = crate::previous_version::capture_previous_version_from_replica(
14958            &client.conn,
14959            &replica_path,
14960            2,
14961            2,
14962            &previous_version_config(),
14963            1,
14964        )
14965        .expect("capture");
14966        assert!(matches!(
14967            outcome,
14968            crate::previous_version::CaptureOutcome::Captured(_)
14969        ));
14970    }
14971
14972    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
14973        Subscription {
14974            id: "wiring-sub".to_owned(),
14975            table: "tasks".to_owned(),
14976            requested: Vec::new(),
14977            params: None,
14978            cursor,
14979            bootstrap_state: bootstrap_state.map(str::to_owned),
14980            state,
14981            reason_code: None,
14982            effective: None,
14983            synced_once: false,
14984        }
14985    }
14986
14987    #[test]
14988    fn previous_version_read_reasons_come_from_live_client_state() {
14989        use crate::previous_version::{
14990            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
14991        };
14992        let path = std::env::temp_dir()
14993            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
14994            .to_string_lossy()
14995            .into_owned();
14996        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
14997        {
14998            let mut setup = SyncClient::open_path(
14999                "wiring".to_owned(),
15000                &previous_version_schema(1),
15001                previous_version_limits(),
15002                &path,
15003            )
15004            .expect("v1 install");
15005            setup
15006                .mutate(vec![Mutation::Upsert {
15007                    table: "tasks".to_owned(),
15008                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
15009                    base_version: None,
15010                }])
15011                .expect("seed a row");
15012        }
15013        let mut client = SyncClient::open_path(
15014            "wiring".to_owned(),
15015            &previous_version_schema(2),
15016            previous_version_limits(),
15017            &path,
15018        )
15019        .expect("v2 bump captures");
15020        assert!(std::path::Path::new(&container).exists());
15021        let spec = PreviousVersionReadSpec {
15022            table: "tasks".to_owned(),
15023            ..Default::default()
15024        };
15025
15026        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
15027        recapture_previous_version(&client);
15028        client.lease_state = Some(LeaseState {
15029            lease_id: None,
15030            expires_at_ms: None,
15031            error_code: Some("lease.expired".to_owned()),
15032        });
15033        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
15034        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
15035        assert!(!snapshot.available);
15036        assert!(!std::path::Path::new(&container).exists());
15037
15038        // An expired lease uses the client clock (injected, no sleeps).
15039        recapture_previous_version(&client);
15040        client.set_now_ms(1_000);
15041        client.lease_state = Some(LeaseState {
15042            lease_id: Some("lease".to_owned()),
15043            expires_at_ms: Some(1_000),
15044            error_code: None,
15045        });
15046        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
15047        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
15048        assert!(!std::path::Path::new(&container).exists());
15049        client.lease_state = None;
15050
15051        // A revoked subscription ⇒ scope-revoked.
15052        recapture_previous_version(&client);
15053        client.subs.push(subscription(SubState::Revoked, 0, None));
15054        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
15055        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
15056        assert!(!std::path::Path::new(&container).exists());
15057        client.subs.clear();
15058
15059        // Coverage completion: every ACTIVE subscription has a cursor and no
15060        // resume token — a still-resuming subscription is not completion.
15061        recapture_previous_version(&client);
15062        client
15063            .subs
15064            .push(subscription(SubState::Active, 0, Some("resume")));
15065        assert!(!client.previous_version_coverage_complete());
15066        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
15067        assert!(snapshot.available);
15068        assert_eq!(snapshot.previous_version, Some(2));
15069        assert_eq!(snapshot.rows.len(), 1);
15070
15071        recapture_previous_version(&client);
15072        client.subs[0].bootstrap_state = None;
15073        assert!(client.previous_version_coverage_complete());
15074        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
15075        assert_eq!(
15076            snapshot.reason,
15077            Some(PreviousVersionReason::CoverageComplete)
15078        );
15079        assert!(!std::path::Path::new(&container).exists());
15080        client.subs.clear();
15081
15082        // With no active subscription at all, coverage is NOT complete.
15083        assert!(!client.previous_version_coverage_complete());
15084
15085        drop(client);
15086        let _ = std::fs::remove_file(&path);
15087    }
15088
15089    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
15090    /// come from live state; this proves the OTHER caller —
15091    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
15092    /// actually removes the container for lease-inactivity, coverage
15093    /// completion and scope revocation, and leaves it alone while the
15094    /// replacement bootstrap is still resuming.
15095    #[test]
15096    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
15097        let path = std::env::temp_dir()
15098            .join(format!(
15099                "syncular-prev-lifetime-{}.db",
15100                uuid::Uuid::new_v4()
15101            ))
15102            .to_string_lossy()
15103            .into_owned();
15104        let container = format!(
15105            "{path}{}",
15106            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
15107        );
15108        let exists = || std::path::Path::new(&container).exists();
15109        {
15110            let mut setup = SyncClient::open_path(
15111                "lifetime".to_owned(),
15112                &previous_version_schema(1),
15113                previous_version_limits(),
15114                &path,
15115            )
15116            .expect("v1 install");
15117            setup
15118                .mutate(vec![Mutation::Upsert {
15119                    table: "tasks".to_owned(),
15120                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
15121                    base_version: None,
15122                }])
15123                .expect("seed a row");
15124        }
15125        let mut client = SyncClient::open_path(
15126            "lifetime".to_owned(),
15127            &previous_version_schema(2),
15128            previous_version_limits(),
15129            &path,
15130        )
15131        .expect("v2 bump captures");
15132        // Pin the clock so the 24h TTL never fires in this test (the TTL has
15133        // its own coverage); the capture record's createdAtMs is 1.
15134        client.set_now_ms(1_000);
15135        assert!(exists());
15136
15137        // §7.3.5 lease error ⇒ the lifetime check drops the container.
15138        client.lease_state = Some(LeaseState {
15139            lease_id: None,
15140            expires_at_ms: None,
15141            error_code: Some("lease.expired".to_owned()),
15142        });
15143        client.previous_version_lifetime_check();
15144        assert!(!exists());
15145        client.lease_state = None;
15146
15147        // §7.4.5 coverage completion (every active sub has a cursor and no
15148        // resume token) ⇒ drop.
15149        recapture_previous_version(&client);
15150        client.subs.push(subscription(SubState::Active, 0, None));
15151        client.previous_version_lifetime_check();
15152        assert!(!exists());
15153        client.subs.clear();
15154
15155        // §3.3 scope revocation ⇒ drop.
15156        recapture_previous_version(&client);
15157        client.subs.push(subscription(SubState::Revoked, 0, None));
15158        client.previous_version_lifetime_check();
15159        assert!(!exists());
15160        client.subs.clear();
15161
15162        // Negative control: a still-resuming active subscription is NOT
15163        // coverage completion, so the container survives the same call.
15164        recapture_previous_version(&client);
15165        client
15166            .subs
15167            .push(subscription(SubState::Active, 0, Some("resume")));
15168        client.previous_version_lifetime_check();
15169        assert!(exists());
15170        client.subs.clear();
15171
15172        drop(client);
15173        let _ = std::fs::remove_file(&path);
15174        let _ = std::fs::remove_file(&container);
15175        let _ = std::fs::remove_file(format!("{container}-wal"));
15176        let _ = std::fs::remove_file(format!("{container}-shm"));
15177    }
15178}