Skip to main content

syncular_client/
client.rs

1//! The Syncular v2 Rust client core (SPEC.md client-behavior contract):
2//! rusqlite local storage, §3.2/§3.3 effective-scope persistence + purge,
3//! §4 pull/cursor/bootstrap (§4.7 resume, §5.6 segment application), §6
4//! push with outbox order, §7 optimistic apply / rollback / replay-on-top,
5//! §2.3 clientCommitId idempotency, §8 realtime client rules, §10 errors.
6//!
7//! Built from `SPEC.md` and the committed `ssp2` codec alone — no
8//! reference to the v1 Rust tree or the v2 TypeScript client.
9
10use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use ssp2::encode_message;
12#[cfg(test)]
13use std::cell::Cell;
14use std::cell::RefCell;
15use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
16
17use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
18use rusqlite::{Connection, OpenFlags, OptionalExtension};
19use serde::{Deserialize, Serialize};
20use serde_json::{json, Map, Value};
21use sha2::{Digest, Sha256};
22use ssp2::decode::WIRE_VERSION;
23use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
24use ssp2::primitives::RawJson;
25use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
26use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
27
28use crate::api::{
29    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31    ClientDiagnosticsStorage, ClientError, ClientLimits, CommandEffects, CommitDelivery,
32    CommitOperation, CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery,
33    CommitOutcomeResolution, CommitOutcomeStatus, ConflictRecord, CoverageSnapshot,
34    DiagnosticLastChange, DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters,
35    DiagnosticSubscription, FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult,
36    LocalDataPurgeTarget, LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation,
37    PresencePeer, PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot,
38    QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
39    RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
40    SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
41    WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
42    CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43    MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49    capture_previous_version_from_replica, first_incompatibility,
50    reconcile_previous_version_at_boot, set_local_schema_descriptor,
51    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58    encode_sparse_row_json, full_row_values, is_canonical_bytes_envelope, json_to_column_value,
59    json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
60    sort_scope_map,
61};
62
63/// §4.2 default: rows and SQLite images, both applied in committed chunks.
64/// Bit 3 (signed URLs, §5.4) is
65/// added per transport capability at request-build time.
66const DEFAULT_ACCEPT: u8 = 0b0111;
67const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
68const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
69const ACCEPT_SQLITE: u8 = 1 << 2;
70const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
71const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
72
73/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
74pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
75const LOCAL_REVISION_KEY: &str = "localRevision";
76const CLIENT_ID_KEY: &str = "clientId";
77const LEASE_STATE_KEY: &str = "leaseState";
78const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
79const LOG_EPOCH_KEY: &str = "logEpoch";
80/// Persisted fail-closed quarantine marker: present while a security preflight
81/// is pending and `activateSecurity` has yet to run. Storing it in the replica
82/// keeps the gate with the data it protects, so a rebuilt host handle reopening
83/// the same file returns in preflight even though its in-memory state is fresh.
84const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
85const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
86const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
87/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
88/// the new schema after a bump. Never a wire code (§10.3).
89const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
90/// §5.11 client-local code: an encode at the push seam resolved no usable key
91/// id or named an unknown key. Never a wire code (§10.3).
92const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
93const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
94/// §7.4.4: static text for a persisted commit the current codec refuses.
95const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
96    "the persisted commit carries values the current codec refuses";
97/// §7.4.4: bounded reason on every intrinsic value drop.
98const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
99
100#[derive(Debug, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase", deny_unknown_fields)]
102struct PersistedLocalDataRebootstrapReceipt {
103    version: u8,
104    retained_commits: u64,
105    reset_subscriptions: u64,
106}
107
108fn invalid_local_rebootstrap_receipt() -> String {
109    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
110}
111
112fn encode_local_rebootstrap_receipt(
113    retained_commits: usize,
114    reset_subscriptions: usize,
115) -> Result<String, String> {
116    let retained_commits =
117        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
118    let reset_subscriptions =
119        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
120    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
121        return Err(invalid_local_rebootstrap_receipt());
122    }
123    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
124        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
125        retained_commits,
126        reset_subscriptions,
127    })
128    .map_err(|_| invalid_local_rebootstrap_receipt())
129}
130
131fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
132    // Pre-0.15.36 markers proved application but did not retain the receipt.
133    if value == "v1" {
134        return Ok((0, 0));
135    }
136    let receipt: PersistedLocalDataRebootstrapReceipt =
137        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
138    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
139        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
140        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
141    {
142        return Err(invalid_local_rebootstrap_receipt());
143    }
144    Ok((
145        usize::try_from(receipt.retained_commits)
146            .map_err(|_| invalid_local_rebootstrap_receipt())?,
147        usize::try_from(receipt.reset_subscriptions)
148            .map_err(|_| invalid_local_rebootstrap_receipt())?,
149    ))
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153enum SubState {
154    Active,
155    Revoked,
156    Failed,
157}
158
159#[cfg(test)]
160mod observation_tests {
161    use super::*;
162    use crate::native_transport::HostTransport;
163    use crate::values::encode_row_json;
164    use serde_json::json;
165
166    #[test]
167    fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
168        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
169            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
170        ]}]});
171        for name in [
172            "secret",
173            "missing",
174            "*",
175            "id FROM authority; DELETE FROM authority",
176            "_syncular_version",
177        ] {
178            let limits = ClientLimits {
179                authority_reads: vec![crate::api::AuthorityReadDeclaration {
180                    table: "authority".into(),
181                    columns: vec!["id".into(), "actor_id".into(), name.into()],
182                    scopes: [("actor_id".into(), vec!["a".into()])].into(),
183                }],
184                ..Default::default()
185            };
186            let error = SyncClient::new("authority".into(), &schema, limits)
187                .err()
188                .expect("forbidden policy");
189            assert!(
190                error.starts_with("client.authority_read_forbidden:"),
191                "{error}"
192            );
193        }
194    }
195
196    #[test]
197    fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
198        let path = std::env::temp_dir().join(format!(
199            "syncular-authority-snapshot-{}.db",
200            uuid::Uuid::new_v4()
201        ));
202        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
203            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
204        ]}]});
205        let limits = ClientLimits {
206            authority_reads: vec![crate::api::AuthorityReadDeclaration {
207                table: "authority".into(),
208                columns: vec!["id".into(), "actor_id".into(), "revision".into()],
209                scopes: [("actor_id".into(), vec!["a".into()])].into(),
210            }],
211            ..Default::default()
212        };
213        let mut client =
214            SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
215                .unwrap();
216        client.begin_security_preflight();
217        let update = |conn: &Connection, revision: i64| {
218            conn.execute_batch("BEGIN IMMEDIATE").unwrap();
219            conn.execute(
220                "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
221                [revision],
222            )
223            .unwrap();
224            conn.execute(
225                "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
226                [revision.to_string()],
227            )
228            .unwrap();
229            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();
230            conn.execute_batch("COMMIT").unwrap();
231        };
232        update(&client.conn, 1);
233        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
234        let writer_barrier = barrier.clone();
235        let writer_path = path.clone();
236        let writer = std::thread::spawn(move || {
237            let conn = Connection::open(writer_path).unwrap();
238            conn.busy_timeout(std::time::Duration::from_secs(5))
239                .unwrap();
240            writer_barrier.wait();
241            for revision in 2..=100 {
242                update(&conn, revision);
243            }
244        });
245        barrier.wait();
246        for _ in 0..100 {
247            let snapshot = client.authority_snapshot().unwrap();
248            let revision = snapshot["revision"]
249                .as_str()
250                .unwrap()
251                .parse::<i64>()
252                .unwrap();
253            assert_eq!(
254                snapshot["tables"][0]["rows"][0]["values"]["revision"],
255                revision
256            );
257            assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
258            assert_eq!(snapshot["complete"], true);
259        }
260        writer.join().unwrap();
261        assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
262        drop(client);
263        let _ = std::fs::remove_file(path);
264    }
265
266    fn client() -> SyncClient {
267        SyncClient::new(
268            "retry-test".to_owned(),
269            &json!({
270                "version": 1,
271                "tables": [{
272                    "name": "tasks",
273                    "primaryKey": "id",
274                    "columns": [
275                        { "name": "id", "type": "string", "nullable": false },
276                        { "name": "project_id", "type": "string", "nullable": false }
277                    ],
278                    "scopes": [{ "pattern": "project:{project_id}" }]
279                }]
280            }),
281            ClientLimits::default(),
282        )
283        .expect("test client")
284    }
285
286    #[cfg(feature = "e2ee")]
287    #[test]
288    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
289        let schema = json!({
290            "version": 1,
291            "tables": [{
292                "name": "secrets",
293                "primaryKey": "id",
294                "columns": [
295                    { "name": "id", "type": "string", "nullable": false },
296                    { "name": "encryption_key_id", "type": "string", "nullable": true },
297                    { "name": "note", "type": "bytes", "nullable": true,
298                      "encrypted": true, "declaredType": "string" }
299                ],
300                "scopes": []
301            }]
302        });
303        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
304            .expect("test client");
305        let mut config = crate::values::EncryptionConfig::default();
306        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
307        config
308            .key_id_columns
309            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
310        client.set_encryption(config);
311        // The row exists, but its key selector is absent. The encrypted edit
312        // authors locally and fails at the push seam.
313        client
314            .write_base_row(
315                "secrets",
316                &vec![Some(ColumnValue::String("ghost".into())), None, None],
317                1,
318            )
319            .unwrap();
320        client.rebuild_overlay().expect("rebuild overlay");
321        let commit_id = client
322            .patch(
323                "secrets",
324                "ghost",
325                Map::from_iter([("note".to_owned(), json!("x"))]),
326                None,
327            )
328            .expect("patch records without an author-time encrypt failure");
329        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
330        // ...and the push seam raises a durable local rejection, not a throw.
331        assert!(client.drop_unencodable_outbox(true, None).unwrap());
332        assert!(client.pending_commit_ids().is_empty());
333        let rejections = client.rejections();
334        assert_eq!(rejections.len(), 1);
335        assert_eq!(rejections[0].code, "client.encrypt_failed");
336        assert_eq!(rejections[0].client_commit_id, commit_id);
337        assert_eq!(
338            client
339                .commit_outcome(&commit_id)
340                .unwrap()
341                .map(|outcome| outcome.status),
342            Some(CommitOutcomeStatus::Rejected)
343        );
344    }
345
346    #[test]
347    fn patch_scope_read_preserves_sqlite_failure() {
348        let mut client = client();
349        client
350            .conn
351            .execute_batch(
352                "DROP TABLE tasks;
353            CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
354            )
355            .unwrap();
356        let error = client
357            .patch(
358                "tasks",
359                "t1",
360                Map::from_iter([("project_id".to_owned(), json!("p1"))]),
361                None,
362            )
363            .expect_err("the scope read must fail");
364        assert_ne!(error.code, "sync.invalid_request");
365        assert_eq!(error.code, "client.failed");
366        assert!(error.details.as_ref().unwrap()["legacyCause"]
367            .as_str()
368            .unwrap()
369            .contains("integer overflow"));
370        assert!(client.pending_commit_ids().is_empty());
371        assert!(client.conn.is_autocommit());
372    }
373
374    #[test]
375    fn patch_with_a_scope_column_matches_the_stored_local_row() {
376        let mut client = client();
377        client.create_synced_tables().unwrap();
378        client
379            .conn
380            .execute(
381                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
382                [],
383            )
384            .unwrap();
385
386        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
387        // the scope column leaves the presence set.
388        client
389            .patch(
390                "tasks",
391                "t1",
392                Map::from_iter([
393                    ("project_id".to_owned(), json!("p1")),
394                    ("id".to_owned(), json!("t1")),
395                ]),
396                None,
397            )
398            .expect("an equal scope column is a no-op");
399        let values = client.outbox[0].ops[0]
400            .values
401            .clone()
402            .expect("the patch records values");
403        assert_eq!(values.get("id"), Some(&json!("t1")));
404        assert!(!values.contains_key("project_id"));
405
406        // A differing value stays rejected.
407        let differing = client.patch(
408            "tasks",
409            "t1",
410            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
411            None,
412        );
413        let differing = differing.expect_err("a differing scope column is rejected");
414        assert_eq!(differing.code, "sync.invalid_request");
415        assert_eq!(differing.message, "the authoring request is invalid");
416        assert!(
417            differing
418                .details
419                .as_ref()
420                .and_then(|details| details["legacyCause"].as_str())
421                .is_some_and(|cause| cause.contains("patch cannot write scope column")),
422            "{differing:?}"
423        );
424
425        // An absent local row leaves nothing to prove equality against, so
426        // the patch fails closed.
427        let absent = client.patch(
428            "tasks",
429            "ghost",
430            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
431            None,
432        );
433        let absent = absent.expect_err("an absent local row is rejected");
434        assert_eq!(absent.code, "sync.row_missing");
435        assert!(absent.message.contains("requires a local row"));
436        assert_eq!(client.pending_commit_ids().len(), 1);
437    }
438
439    #[test]
440    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
441        let schema = json!({
442            "version": 1,
443            "tables": [
444                {
445                    "name": "tenants",
446                    "primaryKey": "tenant_id",
447                    "columns": [
448                        { "name": "tenant_id", "type": "string", "nullable": false },
449                        { "name": "body", "type": "string", "nullable": false }
450                    ],
451                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
452                },
453                {
454                    "name": "buckets",
455                    "primaryKey": "id",
456                    "columns": [
457                        { "name": "id", "type": "string", "nullable": false },
458                        { "name": "bucket", "type": "float", "nullable": false },
459                        { "name": "body", "type": "string", "nullable": false }
460                    ],
461                    "scopes": [{ "pattern": "bucket:{bucket}" }]
462                }
463            ]
464        });
465        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
466            .expect("test client");
467        client.create_synced_tables().unwrap();
468
469        // A primary key that is also a scope column still needs a local base.
470        client
471            .conn
472            .execute(
473                "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
474                [],
475            )
476            .unwrap();
477        client
478            .patch(
479                "tenants",
480                "t1",
481                Map::from_iter([
482                    ("tenant_id".to_owned(), json!("t1")),
483                    ("body".to_owned(), json!("created")),
484                ]),
485                None,
486            )
487            .expect("the primary-key scope column is proven equal by construction");
488
489        // A stored REAL scope value coerces from a supplied integer.
490        client
491            .conn
492            .execute(
493                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
494                [],
495            )
496            .unwrap();
497        client
498            .patch(
499                "buckets",
500                "b1",
501                Map::from_iter([
502                    ("bucket".to_owned(), json!(2)),
503                    ("body".to_owned(), json!("coerced")),
504                ]),
505                None,
506            )
507            .expect("the stored REAL value coerces from a supplied integer");
508        let values = client.outbox[1].ops[0]
509            .values
510            .clone()
511            .expect("the patch records values");
512        assert!(!values.contains_key("bucket"));
513
514        // A differing value stays rejected.
515        let differing = client.patch(
516            "buckets",
517            "b1",
518            Map::from_iter([("bucket".to_owned(), json!(3))]),
519            None,
520        );
521        let differing = differing.expect_err("a differing scope value is rejected");
522        assert_eq!(differing.code, "sync.invalid_request");
523        assert_eq!(differing.message, "the authoring request is invalid");
524        assert!(
525            differing
526                .details
527                .as_ref()
528                .and_then(|details| details["legacyCause"].as_str())
529                .is_some_and(|cause| cause.contains("patch cannot write scope column")),
530            "{differing:?}"
531        );
532    }
533
534    #[cfg(feature = "e2ee")]
535    #[test]
536    fn stored_key_fallback_resolves_an_integer_primary_key() {
537        let schema = json!({
538            "version": 1,
539            "tables": [{
540                "name": "patients",
541                "primaryKey": "id",
542                "columns": [
543                    { "name": "id", "type": "integer", "nullable": false },
544                    { "name": "encryption_key_id", "type": "string", "nullable": true },
545                    { "name": "note", "type": "bytes", "nullable": true,
546                      "encrypted": true, "declaredType": "string" }
547                ],
548                "scopes": []
549            }]
550        });
551        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
552            .expect("test client");
553        client.create_synced_tables().unwrap();
554        client
555            .conn
556            .execute(
557                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
558                 VALUES (5, 'k1', NULL, 1)",
559                [],
560            )
561            .unwrap();
562        let mut config = crate::values::EncryptionConfig::default();
563        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
564        config
565            .key_id_columns
566            .insert("patients".to_owned(), "encryption_key_id".to_owned());
567        client.set_encryption(config);
568        let table = client.schema.table("patients").unwrap();
569        let fallback = client
570            .stored_key_fallback(table, "5")
571            .expect("the stored integer key resolves the fallback");
572        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
573    }
574
575    #[test]
576    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
577        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
578            {"name":"id","type":"string","nullable":false},
579            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
580        for state in ["absent", "cached", "pinned"] {
581            let path = std::env::temp_dir().join(format!(
582                "syncular-blob-stage-{}.sqlite",
583                uuid::Uuid::new_v4()
584            ));
585            let mut client = SyncClient::open_path(
586                "stage-test".into(),
587                &schema,
588                Default::default(),
589                path.to_str().unwrap(),
590            )
591            .unwrap();
592            let bytes = b"atomic stage";
593            if state != "absent" {
594                client
595                    .upload_blob(bytes, Some("text/plain".into()), None)
596                    .unwrap();
597                if state == "cached" {
598                    client
599                        .conn
600                        .execute("DELETE FROM _syncular_blob_uploads", [])
601                        .unwrap();
602                }
603            }
604            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
605            client
606                .conn
607                .execute_batch(
608                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
609                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
610                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
611                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
612                )
613                .unwrap();
614            let error = client
615                .upload_blob(bytes, Some("application/octet-stream".into()), None)
616                .unwrap_err();
617            assert!(error.contains("injected stage failure"), "{state}: {error}");
618            assert!(client.conn.is_autocommit());
619            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
620            drop(client);
621            let mut reopened = SyncClient::open_path(
622                "stage-test".into(),
623                &schema,
624                Default::default(),
625                path.to_str().unwrap(),
626            )
627            .unwrap();
628            let durable = reopened
629                .query("SELECT * FROM _syncular_blobs", &[])
630                .unwrap();
631            reopened
632                .conn
633                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
634                .unwrap();
635            let retried = reopened.upload_blob(bytes, None, None).unwrap();
636            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
637            assert_eq!(
638                reopened
639                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
640                    .unwrap(),
641                vec![Map::from_iter([(
642                    "blob_id".into(),
643                    Value::from(blob_id_for(bytes)),
644                )])]
645            );
646            drop(reopened);
647            std::fs::remove_file(&path).unwrap();
648            assert_eq!(durable, bodies, "{state}: reopened body state");
649            assert_eq!(immediate, bodies, "{state}: immediate body state");
650        }
651    }
652
653    #[test]
654    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
655        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
656            {"name":"id","type":"string","nullable":false},
657            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
658        for fault in [
659            "missing",
660            "body-type",
661            "length",
662            "hash",
663            "pin-metadata",
664            "read",
665            "pin-delete",
666        ] {
667            let path = std::env::temp_dir().join(format!(
668                "syncular-blob-upload-{}.sqlite",
669                uuid::Uuid::new_v4()
670            ));
671            let mut client = SyncClient::open_path(
672                "upload-test".into(),
673                &schema,
674                Default::default(),
675                path.to_str().unwrap(),
676            )
677            .unwrap();
678            let bytes = b"durable upload";
679            let ref_value = client
680                .upload_blob(bytes, Some("text/plain".into()), None)
681                .unwrap();
682            let commit = client
683                .mutate(vec![Mutation::Upsert {
684                    table: "attachments".into(),
685                    values: Map::from_iter([
686                        ("id".into(), Value::from("pending")),
687                        (
688                            "file".into(),
689                            Value::from(format!(
690                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
691                                blob_id_for(bytes),
692                                bytes.len()
693                            )),
694                        ),
695                    ]),
696                    base_version: None,
697                }])
698                .unwrap();
699            client
700                .conn
701                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
702                .unwrap();
703            client.conn.execute_batch(match fault {
704                "missing" => "DELETE FROM _syncular_blobs",
705                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
706                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
707                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
708                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
709                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
710                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
711            }).unwrap();
712            let state = if fault == "read" {
713                Vec::new()
714            } else {
715                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
716            };
717            let mut transport = CountingRealtimeTransport::default();
718            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
719            assert_eq!(
720                error.code,
721                if matches!(fault, "read" | "pin-delete") {
722                    "client.failed"
723                } else {
724                    "sync.local_corrupt"
725                },
726                "{fault}"
727            );
728            assert_eq!(
729                transport.blob_uploads.len(),
730                usize::from(fault == "pin-delete"),
731                "{fault}: corrupt bytes must never be uploaded"
732            );
733            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
734            if fault != "read" {
735                assert_eq!(
736                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
737                    state
738                );
739            } else {
740                client
741                    .conn
742                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
743                    .unwrap();
744            }
745            drop(client);
746            let mut reopened = SyncClient::open_path(
747                "upload-test".into(),
748                &schema,
749                Default::default(),
750                path.to_str().unwrap(),
751            )
752            .unwrap();
753            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
754            if fault != "read" {
755                assert_eq!(
756                    reopened
757                        .query("SELECT * FROM _syncular_blobs", &[])
758                        .unwrap(),
759                    state
760                );
761            }
762            if fault == "pin-delete" {
763                reopened
764                    .conn
765                    .execute_batch("DROP TRIGGER fail_pin_delete")
766                    .unwrap();
767            }
768            if fault == "pin-metadata" {
769                reopened
770                    .conn
771                    .execute(
772                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
773                        [],
774                    )
775                    .unwrap();
776            }
777            reopened
778                .conn
779                .execute_batch(
780                    "DELETE FROM _syncular_blobs;
781                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
782                )
783                .unwrap();
784            transport.blob_uploads.clear();
785            reopened.flush_blob_uploads(&mut transport).unwrap();
786            assert_eq!(
787                transport.blob_uploads,
788                [(blob_id_for(bytes), bytes.to_vec())]
789            );
790            assert_eq!(
791                reopened
792                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
793                    .unwrap(),
794                Vec::<Map<String, Value>>::new()
795            );
796            assert_eq!(reopened.pending_commit_ids(), [commit]);
797            assert_eq!(
798                reopened
799                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
800                    .unwrap()
801                    .unwrap()
802                    .bytes,
803                bytes
804            );
805            drop(reopened);
806            std::fs::remove_file(path).unwrap();
807        }
808    }
809
810    #[test]
811    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
812        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
813            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
814            "scopes":[{"pattern":"project:{project_id}"}]}]});
815        for realtime in [false, true] {
816            for fault in [
817                "none",
818                "row",
819                "revision",
820                "commit",
821                "cursor",
822                "error",
823                "missing-end",
824            ] {
825                if realtime && fault == "missing-end" {
826                    continue;
827                }
828                let path = std::env::temp_dir()
829                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
830                let mut client = SyncClient::open_path(
831                    "frame-test".into(),
832                    &schema,
833                    Default::default(),
834                    path.to_str().unwrap(),
835                )
836                .unwrap();
837                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
838                client
839                    .subscribe(
840                        "tasks".into(),
841                        "tasks".into(),
842                        vec![("project_id".into(), vec!["p1".into()])],
843                        None,
844                    )
845                    .unwrap();
846                client.subs[0].cursor = 0;
847                client.subs[0].synced_once = true;
848                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
849                client.persist_sub(&client.subs[0]).unwrap();
850                client.realtime_state = RealtimeState::Connected;
851                client.realtime_reason_code = None;
852                client.drain_change_batches();
853                let mut transport = CountingRealtimeTransport::default();
854                let (_, meta) = client.build_request(false).unwrap();
855                let mut response = Message {
856                    wire_version: WIRE_VERSION,
857                    msg_kind: MsgKind::Response,
858                    frames: vec![
859                        Frame::RespHeader {
860                            required_schema_version: None,
861                            latest_schema_version: None,
862                            log_epoch: Some("epoch-1".into()),
863                            reset_required: Some(false),
864                        },
865                        Frame::SubStart {
866                            id: "tasks".into(),
867                            status: SubStatus::Active,
868                            reason_code: "".into(),
869                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
870                            bootstrap: false,
871                        },
872                    ],
873                };
874                for (index, id) in ["first", "second"].iter().enumerate() {
875                    let values = Map::from_iter([
876                        ("id".into(), Value::from(*id)),
877                        ("project_id".into(), Value::from("p1")),
878                    ]);
879                    response.frames.push(Frame::Commit {
880                        commit_seq: index as i64 + 1,
881                        actor_id: "writer".into(),
882                        created_at_ms: 1,
883                        tables: vec!["tasks".into()],
884                        changes: vec![ssp2::model::Change {
885                            table_index: 0,
886                            row_id: (*id).into(),
887                            op: Op::Upsert,
888                            row_version: Some(index as i64 + 1),
889                            scopes: vec![("project_id".into(), "p1".into())],
890                            row: Some(
891                                encode_row_json(
892                                    client.schema.table("tasks").unwrap(),
893                                    id,
894                                    &values,
895                                    &client.encryption,
896                                )
897                                .unwrap(),
898                            ),
899                        }],
900                    });
901                }
902                response.frames.push(Frame::SubEnd {
903                    next_cursor: 2,
904                    bootstrap_state: None,
905                });
906                let valid = response.clone();
907                match fault {
908                    "row" => {
909                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
910                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
911                    },
912                    "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(),
913                    "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(),
914                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
915                    "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});},
916                    "missing-end" => {response.frames.truncate(3);},
917                    _ => {}
918                }
919                if realtime {
920                    client.on_realtime_binary(&mut transport, &encode_message(&response));
921                } else {
922                    assert_eq!(
923                        matches!(
924                            client.process_response(&mut transport, response, &meta),
925                            SyncOutcome::Ok(_)
926                        ),
927                        fault == "none",
928                        "{fault}"
929                    );
930                }
931                assert!(
932                    client.conn.is_autocommit(),
933                    "no open transaction after {realtime}/{fault}"
934                );
935                let count = if matches!(fault, "none" | "cursor") {
936                    2
937                } else {
938                    1
939                };
940                let expected_revision = count + u64::from(realtime && fault != "none");
941                assert_eq!(
942                    client.local_revision(),
943                    expected_revision,
944                    "{realtime}/{fault}"
945                );
946                let batches = client.drain_change_batches();
947                assert_eq!(
948                    batches.len(),
949                    expected_revision as usize,
950                    "{realtime}/{fault}"
951                );
952                let row_batches: Vec<_> = batches
953                    .iter()
954                    .filter(|batch| !batch.tables.is_empty())
955                    .collect();
956                assert_eq!(row_batches.len(), count as usize);
957                assert_eq!(
958                    row_batches
959                        .iter()
960                        .map(|b| b.revision.clone())
961                        .collect::<Vec<_>>(),
962                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
963                );
964                for table in ["tasks", "_syncular_base_tasks"] {
965                    assert_eq!(
966                        client
967                            .conn
968                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
969                                .get::<_, u64>(0))
970                            .unwrap(),
971                        count,
972                        "{realtime}/{fault}/{table}"
973                    );
974                }
975                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
976                assert_eq!(
977                    transport
978                        .messages
979                        .iter()
980                        .filter(|m| m.contains("\"ack\""))
981                        .count(),
982                    usize::from(fault == "none")
983                );
984                if matches!(fault, "revision" | "commit" | "cursor") {
985                    client
986                        .conn
987                        .execute_batch("DROP TRIGGER fail_frame")
988                        .unwrap();
989                }
990                drop(client);
991                let mut reopened = SyncClient::open_path(
992                    "frame-test".into(),
993                    &schema,
994                    Default::default(),
995                    path.to_str().unwrap(),
996                )
997                .unwrap();
998                assert_eq!(reopened.local_revision(), expected_revision);
999                assert_eq!(
1000                    reopened
1001                        .query("SELECT id FROM tasks ORDER BY id", &[])
1002                        .unwrap()
1003                        .len(),
1004                    count as usize
1005                );
1006                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1007                let (_, meta) = reopened.build_request(false).unwrap();
1008                assert!(matches!(
1009                    reopened.process_response(&mut transport, valid, &meta),
1010                    SyncOutcome::Ok(_)
1011                ));
1012                assert_eq!(
1013                    reopened
1014                        .query("SELECT id FROM tasks ORDER BY id", &[])
1015                        .unwrap()
1016                        .len(),
1017                    2
1018                );
1019                assert_eq!(reopened.subs[0].cursor, 2);
1020                drop(reopened);
1021                std::fs::remove_file(path).unwrap();
1022            }
1023        }
1024    }
1025
1026    #[test]
1027    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1028        for status in [SubStatus::Reset, SubStatus::Revoked] {
1029            for fault in ["subscription", "revision"] {
1030                let mut client = client();
1031                client
1032                    .subscribe(
1033                        "tasks".into(),
1034                        "tasks".into(),
1035                        vec![("project_id".into(), vec!["p1".into()])],
1036                        None,
1037                    )
1038                    .unwrap();
1039                client.subs[0].cursor = 41;
1040                client.subs[0].synced_once = true;
1041                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1042                client.persist_sub(&client.subs[0]).unwrap();
1043                client
1044                    .mutate(vec![Mutation::Upsert {
1045                        table: "tasks".into(),
1046                        values: Map::from_iter([
1047                            ("id".into(), Value::from("pending")),
1048                            ("project_id".into(), Value::from("p1")),
1049                        ]),
1050                        base_version: None,
1051                    }])
1052                    .unwrap();
1053                let ids = client.pending_commit_ids();
1054                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1055                let revision = client.local_revision();
1056                client.drain_change_batches();
1057                let (_, meta) = client.build_request(false).unwrap();
1058                client.conn.execute_batch(if fault == "subscription" {
1059                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1060                } else {
1061                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1062                }).unwrap();
1063                let mut transport = CountingRealtimeTransport::default();
1064                let mut report = SyncReport::default();
1065                // Reset changes window completeness and therefore needs a revision.
1066                if status == SubStatus::Reset {
1067                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1068                }
1069                let result = client.process_section(
1070                    &mut transport,
1071                    "tasks",
1072                    status,
1073                    "sync.scope_revoked",
1074                    vec![],
1075                    vec![],
1076                    Some((42, None)),
1077                    &meta,
1078                    &mut report,
1079                );
1080                assert!(result.is_err(), "{status:?}/{fault}");
1081                assert!(client.conn.is_autocommit());
1082                assert_eq!(client.subs[0].cursor, 41);
1083                assert_eq!(client.subs[0].state, SubState::Active);
1084                assert_eq!(client.pending_commit_ids(), ids);
1085                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1086                assert!(client.rejections.is_empty());
1087                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1088                assert_eq!(client.local_revision(), revision);
1089                assert!(client.drain_change_batches().is_empty());
1090                assert!(report.revoked.is_empty() && report.resets.is_empty());
1091            }
1092        }
1093    }
1094
1095    #[test]
1096    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1097        let mut client = client();
1098        client
1099            .subscribe(
1100                "tasks".into(),
1101                "tasks".into(),
1102                vec![("project_id".into(), vec!["p1".into()])],
1103                None,
1104            )
1105            .unwrap();
1106        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1107        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1108            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1109        let row = |id: &str| ssp2::segment::SegmentRow {
1110            server_version: 1,
1111            values: vec![
1112                Some(ColumnValue::String(id.into())),
1113                Some(ColumnValue::String("p1".into())),
1114            ],
1115        };
1116        let segment = RowsSegment {
1117            table: "tasks".into(),
1118            schema_version: 1,
1119            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1120            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1121        };
1122        assert!(client.apply_segment(0, &segment, true).is_err());
1123        assert!(client.conn.is_autocommit());
1124        assert_eq!(
1125            client
1126                .query("SELECT id FROM tasks ORDER BY id", &[])
1127                .unwrap(),
1128            vec![Map::from_iter([("id".into(), Value::from("first"))])]
1129        );
1130        assert_eq!(client.local_revision(), 1);
1131        assert_eq!(client.drain_change_batches().len(), 1);
1132        assert_eq!(client.subs[0].cursor, -1);
1133        client
1134            .conn
1135            .execute_batch("DROP TRIGGER fail_block")
1136            .unwrap();
1137        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1138        assert_eq!(client.local_revision(), 3);
1139        assert_eq!(client.drain_change_batches().len(), 2);
1140    }
1141
1142    #[test]
1143    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1144        assert_eq!(
1145            client().diagnostics_storage().blob_cache_bytes_approx,
1146            Some(0)
1147        );
1148        let client = SyncClient::new(
1149            "diagnostic-storage".to_owned(),
1150            &json!({
1151                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1152                    "columns": [{"name": "id", "type": "string", "nullable": false},
1153                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1154            }),
1155            ClientLimits {
1156                blob_cache_max_bytes: Some(5),
1157                ..Default::default()
1158            },
1159        )
1160        .unwrap();
1161        let empty = client.diagnostics_storage();
1162        assert_eq!(empty.status, "healthy");
1163        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1164        assert_eq!(empty.retained_outcome_entries, Some(0));
1165        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1166        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1167        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1168            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1169                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1170            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1171                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1172        let written = client.diagnostics_storage();
1173        assert_eq!(written.status, "pressure");
1174        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1175        assert_eq!(written.retained_outcome_entries, Some(2));
1176        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1177        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1178        assert_eq!(
1179            written.pressure_reason_code.as_deref(),
1180            Some("client.blob_cache_over_limit")
1181        );
1182        client
1183            .conn
1184            .execute_batch(
1185                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1186            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1187            )
1188            .unwrap();
1189        let staged = client.diagnostics_storage();
1190        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1191        assert_eq!(staged.retained_outcome_entries, Some(0));
1192        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1193        assert_eq!(staged.status, "healthy");
1194        client.conn.execute_batch("ROLLBACK").unwrap();
1195        assert_eq!(client.diagnostics_storage(), written);
1196        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1197        let unreadable = client.diagnostics_storage();
1198        assert_eq!(unreadable.status, "unreadable");
1199        assert!(unreadable.database_bytes_approx.is_none());
1200        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1201        assert!(unreadable.retained_outcome_entries.is_none());
1202        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1203        assert!(unreadable.blob_cache_bytes_approx.is_none());
1204        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1205        let restored = client.diagnostics_storage();
1206        assert_eq!(restored.status, written.status);
1207        assert_eq!(
1208            restored.retained_outcome_bytes_approx,
1209            written.retained_outcome_bytes_approx
1210        );
1211        client.conn.set_prepared_statement_cache_capacity(0);
1212        assert_eq!(client.diagnostics_storage(), restored);
1213    }
1214
1215    /// Answers every round with one server `ERROR` frame (§1.6).
1216    struct ServerErrorTransport {
1217        code: &'static str,
1218        retryable: bool,
1219    }
1220
1221    impl Transport for ServerErrorTransport {
1222        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1223            Ok(encode_message(&Message {
1224                wire_version: WIRE_VERSION,
1225                msg_kind: MsgKind::Response,
1226                frames: vec![
1227                    Frame::RespHeader {
1228                        required_schema_version: None,
1229                        latest_schema_version: None,
1230                        log_epoch: Some("epoch-1".to_owned()),
1231                        reset_required: Some(false),
1232                    },
1233                    Frame::Error {
1234                        code: self.code.to_owned(),
1235                        message: "server error".to_owned(),
1236                        category: "internal".to_owned(),
1237                        retryable: self.retryable,
1238                        recommended_action: "retryLater".to_owned(),
1239                        details: None,
1240                    },
1241                ],
1242            }))
1243        }
1244
1245        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1246            self.sync(request)
1247        }
1248
1249        fn download_segment(
1250            &mut self,
1251            _request: &SegmentRequest,
1252            _on_progress: &mut dyn FnMut(u64),
1253        ) -> Result<Vec<u8>, TransportError> {
1254            Err(TransportError::new("sync.transport_failed", "offline"))
1255        }
1256
1257        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1258            Ok(())
1259        }
1260
1261        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1262            Ok(())
1263        }
1264
1265        fn realtime_close(&mut self) -> Result<(), TransportError> {
1266            Ok(())
1267        }
1268    }
1269
1270    #[test]
1271    fn retryable_server_error_schedules_a_background_retry() {
1272        let mut client = client();
1273        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1274        let mut transport = ServerErrorTransport {
1275            code: "sync.internal_error",
1276            retryable: true,
1277        };
1278        assert!(matches!(
1279            client.sync(&mut transport),
1280            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1281        ));
1282        assert!(matches!(
1283            client.drain_sync_intents().as_slice(),
1284            [SyncIntent::Background { delay_ms: 250 }]
1285        ));
1286        assert_eq!(
1287            client.progress().snapshot().unwrap().retry_delay_ms,
1288            Some(250)
1289        );
1290        let mut transport = ServerErrorTransport {
1291            code: "sync.invalid_request",
1292            retryable: false,
1293        };
1294        assert!(matches!(
1295            client.sync(&mut transport),
1296            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1297        ));
1298        assert!(client.drain_sync_intents().is_empty());
1299        let progress = client.progress().snapshot().unwrap();
1300        assert_eq!(progress.state, ProgressState::Failed);
1301        assert_eq!(progress.retry_delay_ms, None);
1302    }
1303
1304    #[test]
1305    fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1306        // §7.7: the exhausted outcome is a partial success, not a failure,
1307        // and serializes with an explicit flag.
1308        let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1309        assert_eq!(exhausted["ok"], json!(true));
1310        assert_eq!(exhausted["budgetExhausted"], json!(true));
1311        assert!(exhausted["report"].is_object());
1312
1313        let mut client = client();
1314        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1315        // Zero explicit budget is a caller error, not an exhaustion.
1316        let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1317        let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1318        assert!(
1319            matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1320            "{zero:?}"
1321        );
1322        // A real server failure stays a failure across the budget loop.
1323        let mut erroring = ServerErrorTransport {
1324            code: "sync.internal_error",
1325            retryable: false,
1326        };
1327        let failed = client.sync_until_idle(&mut erroring, Some(3));
1328        assert!(
1329            matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1330            "{failed:?}"
1331        );
1332    }
1333
1334    /// Returns one pre-built response per round so a test can pin a successful
1335    /// work-bearing round followed by a failure or a schema floor.
1336    struct ScriptedSyncTransport {
1337        responses: std::collections::VecDeque<Message>,
1338        calls: usize,
1339    }
1340
1341    impl Transport for ScriptedSyncTransport {
1342        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1343            self.calls += 1;
1344            let response = self.responses.pop_front().expect("scripted response");
1345            Ok(encode_message(&response))
1346        }
1347
1348        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1349            self.sync(request)
1350        }
1351
1352        fn download_segment(
1353            &mut self,
1354            _request: &SegmentRequest,
1355            _on_progress: &mut dyn FnMut(u64),
1356        ) -> Result<Vec<u8>, TransportError> {
1357            Err(TransportError::new("sync.transport_failed", "offline"))
1358        }
1359
1360        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1361            Ok(())
1362        }
1363
1364        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1365            Ok(())
1366        }
1367
1368        fn realtime_close(&mut self) -> Result<(), TransportError> {
1369            Ok(())
1370        }
1371    }
1372
1373    #[test]
1374    fn sync_until_idle_success_then_failure_keeps_earlier_work() {
1375        let path = std::env::temp_dir().join(format!(
1376            "syncular-budget-script-{}.sqlite",
1377            uuid::Uuid::new_v4()
1378        ));
1379        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1380            {"name":"id","type":"string","nullable":false},
1381            {"name":"project_id","type":"string","nullable":false}],
1382            "scopes":[{"pattern":"project:{project_id}"}]}]});
1383        let mut client = SyncClient::open_path(
1384            "budget-script".into(),
1385            &schema,
1386            ClientLimits::default(),
1387            path.to_str().unwrap(),
1388        )
1389        .expect("open client");
1390        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1391        client
1392            .subscribe(
1393                "tasks".into(),
1394                "tasks".into(),
1395                vec![("project_id".into(), vec!["p1".into()])],
1396                None,
1397            )
1398            .unwrap();
1399        client.subs[0].cursor = 0;
1400        client.subs[0].synced_once = true;
1401        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1402        client.persist_sub(&client.subs[0]).unwrap();
1403        client.drain_change_batches();
1404
1405        let row = encode_row_json(
1406            client.schema.table("tasks").unwrap(),
1407            "kept",
1408            &Map::from_iter([
1409                ("id".into(), json!("kept")),
1410                ("project_id".into(), json!("p1")),
1411            ]),
1412            &client.encryption,
1413        )
1414        .unwrap();
1415        let success = Message {
1416            wire_version: WIRE_VERSION,
1417            msg_kind: MsgKind::Response,
1418            frames: vec![
1419                Frame::RespHeader {
1420                    required_schema_version: None,
1421                    latest_schema_version: None,
1422                    log_epoch: Some("epoch-1".into()),
1423                    reset_required: Some(false),
1424                },
1425                Frame::SubStart {
1426                    id: "tasks".into(),
1427                    status: SubStatus::Active,
1428                    reason_code: String::new(),
1429                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1430                    bootstrap: false,
1431                },
1432                Frame::Commit {
1433                    commit_seq: 1,
1434                    created_at_ms: 1,
1435                    actor_id: "writer".into(),
1436                    tables: vec!["tasks".into()],
1437                    changes: vec![ssp2::model::Change {
1438                        table_index: 0,
1439                        row_id: "kept".into(),
1440                        op: Op::Upsert,
1441                        row_version: Some(1),
1442                        scopes: vec![("project_id".into(), "p1".into())],
1443                        row: Some(row),
1444                    }],
1445                },
1446                Frame::SubEnd {
1447                    next_cursor: 1,
1448                    bootstrap_state: None,
1449                },
1450            ],
1451        };
1452        let failure = Message {
1453            wire_version: WIRE_VERSION,
1454            msg_kind: MsgKind::Response,
1455            frames: vec![
1456                Frame::RespHeader {
1457                    required_schema_version: None,
1458                    latest_schema_version: None,
1459                    log_epoch: Some("epoch-1".into()),
1460                    reset_required: Some(false),
1461                },
1462                Frame::Error {
1463                    code: "sync.internal_error".into(),
1464                    message: "second round failed".into(),
1465                    category: "internal".into(),
1466                    retryable: false,
1467                    recommended_action: "retryLater".into(),
1468                    details: None,
1469                },
1470            ],
1471        };
1472        let mut transport = ScriptedSyncTransport {
1473            responses: std::collections::VecDeque::from([success, failure]),
1474            calls: 0,
1475        };
1476        let outcome = client.sync_until_idle(&mut transport, Some(2));
1477        assert!(
1478            matches!(outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1479            "{outcome:?}"
1480        );
1481        assert_eq!(
1482            transport.calls, 2,
1483            "one successful round then the failing round"
1484        );
1485        assert_eq!(
1486            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
1487            json!("kept"),
1488            "the earlier round's durable work survives the later failure"
1489        );
1490        drop(client);
1491        let _ = std::fs::remove_file(&path);
1492    }
1493
1494    #[test]
1495    fn sync_until_idle_stops_on_a_schema_floor() {
1496        let mut client = client();
1497        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1498        let floor = Message {
1499            wire_version: WIRE_VERSION,
1500            msg_kind: MsgKind::Response,
1501            frames: vec![Frame::RespHeader {
1502                required_schema_version: Some(2),
1503                latest_schema_version: Some(2),
1504                log_epoch: Some("epoch-1".into()),
1505                reset_required: Some(false),
1506            }],
1507        };
1508        let mut transport = ScriptedSyncTransport {
1509            responses: std::collections::VecDeque::from([floor.clone(), floor]),
1510            calls: 0,
1511        };
1512        let outcome = client.sync_until_idle(&mut transport, Some(3));
1513        match outcome {
1514            SyncOutcome::Ok(report) => assert_eq!(
1515                report
1516                    .schema_floor
1517                    .as_ref()
1518                    .and_then(|floor| floor.required_schema_version),
1519                Some(2),
1520                "the stop retains the latest schema floor"
1521            ),
1522            other => panic!("expected a schema-floor stop, got {other:?}"),
1523        }
1524        assert_eq!(
1525            transport.calls, 1,
1526            "a schema floor stops the budget loop after one round"
1527        );
1528    }
1529
1530    #[test]
1531    fn sync_report_merge_schema_floor_is_latest_state() {
1532        let mut aggregate = SyncReport {
1533            schema_floor: Some(SchemaFloor {
1534                required_schema_version: Some(2),
1535                latest_schema_version: Some(2),
1536            }),
1537            ..SyncReport::default()
1538        };
1539        aggregate.merge(&SyncReport::default());
1540        assert!(
1541            aggregate.schema_floor.is_none(),
1542            "a later round without a floor clears it"
1543        );
1544    }
1545
1546    #[test]
1547    fn push_capacity_limits_are_hard_and_keep_intent() {
1548        // The configured operation cap is hard for the first commit.
1549        let mut c = client();
1550        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1551        c.limits.max_push_operations_per_request = Some(1);
1552        let id = c
1553            .mutate(vec![
1554                Mutation::Upsert {
1555                    table: "tasks".into(),
1556                    values: Map::from_iter([
1557                        ("id".into(), json!("a")),
1558                        ("project_id".into(), json!("p1")),
1559                    ]),
1560                    base_version: None,
1561                },
1562                Mutation::Upsert {
1563                    table: "tasks".into(),
1564                    values: Map::from_iter([
1565                        ("id".into(), json!("b")),
1566                        ("project_id".into(), json!("p1")),
1567                    ]),
1568                    base_version: None,
1569                },
1570            ])
1571            .unwrap();
1572        let error = c.build_request(false).unwrap_err();
1573        let SyncOutcome::Failed {
1574            error_code,
1575            details,
1576            ..
1577        } = *error
1578        else {
1579            panic!("expected a capacity failure");
1580        };
1581        assert_eq!(error_code, "client.push_request_too_large");
1582        let details = details.expect("capacity details");
1583        assert_eq!(details["kind"], json!("operations"));
1584        assert_eq!(details["limit"], json!(1));
1585        assert_eq!(details["size"], json!(2));
1586        assert_eq!(details["clientCommitId"], json!(id));
1587        assert_eq!(c.pending_commit_ids(), vec![id]);
1588
1589        // The commit-count cap defers whole later commits.
1590        let mut c = client();
1591        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1592        c.limits.max_push_commits_per_request = Some(1);
1593        let first = c
1594            .mutate(vec![Mutation::Upsert {
1595                table: "tasks".into(),
1596                values: Map::from_iter([
1597                    ("id".into(), json!("one")),
1598                    ("project_id".into(), json!("p1")),
1599                ]),
1600                base_version: None,
1601            }])
1602            .unwrap();
1603        let second = c
1604            .mutate(vec![Mutation::Upsert {
1605                table: "tasks".into(),
1606                values: Map::from_iter([
1607                    ("id".into(), json!("two")),
1608                    ("project_id".into(), json!("p1")),
1609                ]),
1610                base_version: None,
1611            }])
1612            .unwrap();
1613        let (_, meta) = c.build_request(false).unwrap();
1614        assert_eq!(meta.pushed_ids, vec![first.clone()]);
1615        assert_eq!(meta.deferred_commits, 1);
1616        assert_eq!(c.pending_commit_ids(), vec![first, second]);
1617    }
1618
1619    #[test]
1620    fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
1621        // Two small commits: a cap just below the full request defers the
1622        // last commit and sends the fitting prefix (header + first commit).
1623        let mut c = client();
1624        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1625        let first = c
1626            .mutate(vec![Mutation::Upsert {
1627                table: "tasks".into(),
1628                values: Map::from_iter([
1629                    ("id".into(), json!("one")),
1630                    ("project_id".into(), json!("p1")),
1631                ]),
1632                base_version: None,
1633            }])
1634            .unwrap();
1635        let second = c
1636            .mutate(vec![Mutation::Upsert {
1637                table: "tasks".into(),
1638                values: Map::from_iter([
1639                    ("id".into(), json!("two")),
1640                    ("project_id".into(), json!("p1")),
1641                ]),
1642                base_version: None,
1643            }])
1644            .unwrap();
1645        let (message, meta) = c.build_request(false).unwrap();
1646        assert_eq!(meta.pushed_ids.len(), 2);
1647        let full = encode_message(&message).len();
1648        c.limits.max_push_request_bytes = Some(full - 1);
1649        let (_, meta) = c.build_request(false).unwrap();
1650        assert_eq!(meta.pushed_ids, vec![first.clone()]);
1651        assert_eq!(meta.deferred_commits, 1);
1652        assert_eq!(c.pending_commit_ids(), vec![first, second]);
1653
1654        // A single commit larger than the budget is a typed capacity error and
1655        // stays queued, reporting the full projected request size.
1656        let mut c = client();
1657        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1658        let huge = c
1659            .mutate(vec![Mutation::Upsert {
1660                table: "tasks".into(),
1661                values: Map::from_iter([
1662                    ("id".into(), json!("x".repeat(20_000))),
1663                    ("project_id".into(), json!("p1")),
1664                ]),
1665                base_version: None,
1666            }])
1667            .unwrap();
1668        let (message, meta) = c.build_request(false).unwrap();
1669        assert_eq!(meta.pushed_ids, vec![huge.clone()]);
1670        let with_huge = encode_message(&message).len();
1671        c.limits.max_push_request_bytes = Some(with_huge - 1);
1672        let error = c.build_request(false).unwrap_err();
1673        let SyncOutcome::Failed {
1674            error_code,
1675            details,
1676            ..
1677        } = *error
1678        else {
1679            panic!("expected a capacity failure");
1680        };
1681        assert_eq!(error_code, "client.push_request_too_large");
1682        let details = details.expect("capacity details");
1683        assert_eq!(details["kind"], json!("bytes"));
1684        assert_eq!(details["limit"], json!((with_huge - 1) as u64));
1685        assert_eq!(details["size"], json!(with_huge as u64));
1686        assert_eq!(details["clientCommitId"], json!(huge));
1687        assert_eq!(c.pending_commit_ids(), vec![huge]);
1688
1689        // A cap smaller than the fixed frames reports no blocked commit.
1690        let mut c = client();
1691        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1692        c.limits.max_push_request_bytes = Some(1);
1693        let error = c.build_request(false).unwrap_err();
1694        let SyncOutcome::Failed { details, .. } = *error else {
1695            panic!("expected a capacity failure");
1696        };
1697        let details = details.expect("capacity details");
1698        assert_eq!(details["kind"], json!("bytes"));
1699        assert!(details.get("clientCommitId").is_none(), "{details}");
1700    }
1701
1702    #[test]
1703    fn push_limits_validate_at_construction() {
1704        for limits in [
1705            ClientLimits {
1706                max_push_commits_per_request: Some(0),
1707                ..Default::default()
1708            },
1709            ClientLimits {
1710                max_push_operations_per_request: Some(0),
1711                ..Default::default()
1712            },
1713            ClientLimits {
1714                max_push_request_bytes: Some(0),
1715                ..Default::default()
1716            },
1717            ClientLimits {
1718                max_push_request_bytes: Some(u32::MAX as usize + 1),
1719                ..Default::default()
1720            },
1721        ] {
1722            let schema = json!({"version":1,"tables":[]});
1723            let error = SyncClient::with_connection(
1724                "bad-limits".into(),
1725                &schema,
1726                limits,
1727                Connection::open_in_memory().unwrap(),
1728            )
1729            .err();
1730            assert_eq!(
1731                error.as_deref(),
1732                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
1733            );
1734        }
1735    }
1736
1737    #[test]
1738    fn open_path_rejects_invalid_push_limits_before_storage() {
1739        for identity in [false, true] {
1740            let path = std::env::temp_dir().join(format!(
1741                "syncular-bad-limits-{}.sqlite",
1742                uuid::Uuid::new_v4()
1743            ));
1744            let schema = json!({"version":1,"tables":[]});
1745            let limits = ClientLimits {
1746                max_push_request_bytes: Some(0),
1747                ..Default::default()
1748            };
1749            let error = if identity {
1750                SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
1751            } else {
1752                SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
1753            }
1754            .err();
1755            assert_eq!(
1756                error.as_deref(),
1757                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
1758            );
1759            assert!(
1760                !path.exists(),
1761                "an invalid limit is rejected before the database file is created"
1762            );
1763        }
1764    }
1765
1766    #[test]
1767    fn failed_progress_retry_delay_doubles_to_the_cap() {
1768        let mut client = client();
1769        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1770        let mut transport = ServerErrorTransport {
1771            code: "sync.internal_error",
1772            retryable: true,
1773        };
1774        let delays: Vec<Option<u64>> = (0..9)
1775            .map(|_| {
1776                client.sync(&mut transport);
1777                client.progress().snapshot().unwrap().retry_delay_ms
1778            })
1779            .collect();
1780        assert_eq!(
1781            delays,
1782            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1783        );
1784    }
1785
1786    #[test]
1787    fn background_retry_deadlines_back_off_and_reset() {
1788        let mut client = client();
1789        client.schedule_background_retry();
1790        assert!(matches!(
1791            client.drain_sync_intents().as_slice(),
1792            [SyncIntent::Background { delay_ms: 250 }]
1793        ));
1794        client.schedule_background_retry();
1795        assert!(matches!(
1796            client.drain_sync_intents().as_slice(),
1797            [SyncIntent::Background { delay_ms: 500 }]
1798        ));
1799        client.reset_background_retry();
1800        client.schedule_background_retry();
1801        assert!(matches!(
1802            client.drain_sync_intents().as_slice(),
1803            [SyncIntent::Background { delay_ms: 250 }]
1804        ));
1805    }
1806
1807    #[test]
1808    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1809        let mut client = client();
1810        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1811        client
1812            .subscribe(
1813                "epoch-tasks".to_owned(),
1814                "tasks".to_owned(),
1815                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1816                None,
1817            )
1818            .expect("subscribe");
1819        client.drain_sync_intents();
1820
1821        client
1822            .run_log_epoch_reset("epoch-2")
1823            .expect("reset partition log epoch");
1824
1825        assert!(matches!(
1826            client.drain_sync_intents().as_slice(),
1827            [SyncIntent::Interactive]
1828        ));
1829    }
1830
1831    #[derive(Default)]
1832    struct CountingRealtimeTransport {
1833        connects: usize,
1834        closes: usize,
1835        messages: Vec<String>,
1836        blob_uploads: Vec<(String, Vec<u8>)>,
1837        blob_download: Option<Vec<u8>>,
1838        sync_calls: usize,
1839        realtime_sync_calls: usize,
1840        refuse_connect: bool,
1841    }
1842
1843    impl Transport for CountingRealtimeTransport {
1844        fn blob_upload(
1845            &mut self,
1846            blob_id: &str,
1847            bytes: &[u8],
1848            _media_type: Option<&str>,
1849        ) -> Result<(), TransportError> {
1850            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1851            Ok(())
1852        }
1853
1854        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1855            self.blob_download
1856                .clone()
1857                .map(BlobDownload::Bytes)
1858                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1859        }
1860
1861        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1862            self.sync_calls += 1;
1863            Err(TransportError::new("sync.transport_failed", "offline"))
1864        }
1865
1866        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1867            self.realtime_sync_calls += 1;
1868            Err(TransportError::new("sync.transport_failed", "offline"))
1869        }
1870
1871        fn download_segment(
1872            &mut self,
1873            _request: &SegmentRequest,
1874            _on_progress: &mut dyn FnMut(u64),
1875        ) -> Result<Vec<u8>, TransportError> {
1876            Err(TransportError::new("sync.transport_failed", "offline"))
1877        }
1878
1879        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1880            self.connects += 1;
1881            if self.refuse_connect {
1882                return Err(TransportError::new("transport.failed", "refused"));
1883            }
1884            Ok(())
1885        }
1886
1887        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1888            self.messages.push(text.to_owned());
1889            Ok(())
1890        }
1891
1892        fn realtime_close(&mut self) -> Result<(), TransportError> {
1893            self.closes += 1;
1894            Ok(())
1895        }
1896    }
1897
1898    #[test]
1899    fn blob_results_own_downloaded_bytes() {
1900        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1901            {"name":"id","type":"string","nullable":false},
1902            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1903        let bytes = b"owned typed result".to_vec();
1904        let blob_id = blob_id_for(&bytes);
1905        let mut transport = CountingRealtimeTransport {
1906            blob_download: Some(bytes.clone()),
1907            ..Default::default()
1908        };
1909        let result = {
1910            let mut client =
1911                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1912                    .unwrap();
1913            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1914            assert_eq!(result.blob_id, blob_id);
1915            assert_eq!(result.byte_length, bytes.len() as i64);
1916            assert_eq!(result.bytes, bytes);
1917            assert_eq!(result.media_type, None);
1918            assert_eq!(
1919                client
1920                    .conn
1921                    .query_row(
1922                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1923                        rusqlite::params![blob_id],
1924                        |row| row.get::<_, i64>(0),
1925                    )
1926                    .unwrap(),
1927                0
1928            );
1929            result
1930        };
1931        assert_eq!(result.bytes, bytes);
1932
1933        let mut client =
1934            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1935        assert_eq!(
1936            client
1937                .fetch_blob_bytes(&mut transport, "not json")
1938                .unwrap_err(),
1939            (
1940                "client.failed".to_owned(),
1941                "blob ref is not JSON".to_owned()
1942            )
1943        );
1944    }
1945
1946    #[test]
1947    fn visible_blob_references_survive_cache_trimming() {
1948        let schema = json!({"version":1,"tables":[
1949            {"name":"attachments","primaryKey":"id","columns":[
1950                {"name":"id","type":"string","nullable":false},
1951                {"name":"body","type":"blob_ref","nullable":true},
1952                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1953            {"name":"avatars","primaryKey":"id","columns":[
1954                {"name":"id","type":"string","nullable":false},
1955                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1956        ]});
1957        let bytes = b"referenced above cap".to_vec();
1958        let blob_id = blob_id_for(&bytes);
1959        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1960        let mut client = SyncClient::new(
1961            "visible-blob-refs".to_owned(),
1962            &schema,
1963            ClientLimits {
1964                blob_cache_max_bytes: Some(1),
1965                ..Default::default()
1966            },
1967        )
1968        .unwrap();
1969        client.conn.execute(
1970            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1971            rusqlite::params![reference, reference, reference, reference],
1972        ).unwrap();
1973        client
1974            .conn
1975            .execute(
1976                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1977                rusqlite::params![reference],
1978            )
1979            .unwrap();
1980        client.conn.execute(
1981            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1982            [],
1983        ).unwrap();
1984        let mut transport = CountingRealtimeTransport {
1985            blob_download: Some(bytes.clone()),
1986            ..Default::default()
1987        };
1988
1989        assert_eq!(
1990            client
1991                .fetch_blob_bytes(&mut transport, &blob_id)
1992                .unwrap()
1993                .bytes,
1994            bytes
1995        );
1996        let rows = client
1997            .conn
1998            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1999            .unwrap()
2000            .query_map([], |row| row.get::<_, String>(0))
2001            .unwrap()
2002            .collect::<Result<Vec<_>, _>>()
2003            .unwrap();
2004        assert_eq!(rows, vec![blob_id]);
2005    }
2006
2007    #[test]
2008    fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
2009        let mut transport = CountingRealtimeTransport::default();
2010        let path = std::env::temp_dir().join(format!(
2011            "syncular-transport-gate-{}.db",
2012            uuid::Uuid::new_v4()
2013        ));
2014        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2015            {"name":"id","type":"string","nullable":false},
2016            {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
2017        let mut client = SyncClient::open_path(
2018            "gate".into(),
2019            &schema,
2020            ClientLimits::default(),
2021            path.to_str().unwrap(),
2022        )
2023        .unwrap();
2024        client.connect_realtime(&mut transport).unwrap();
2025        client.set_transport_enabled(&mut transport, false);
2026        assert_eq!(transport.closes, 1);
2027        client.begin_security_preflight();
2028        client.activate_security(Default::default()).unwrap();
2029        client
2030            .subscribe(
2031                "tasks".into(),
2032                "tasks".into(),
2033                vec![("project_id".into(), vec!["p1".into()])],
2034                None,
2035            )
2036            .unwrap();
2037        for id in ["one", "two"] {
2038            client
2039                .mutate(vec![Mutation::Upsert {
2040                    table: "tasks".into(),
2041                    values: Map::from_iter([
2042                        ("id".into(), json!(id)),
2043                        ("project_id".into(), json!("p1")),
2044                    ]),
2045                    base_version: None,
2046                }])
2047                .unwrap();
2048        }
2049        assert_eq!(
2050            client
2051                .query("SELECT id FROM tasks ORDER BY id", &[])
2052                .unwrap()
2053                .len(),
2054            2
2055        );
2056        assert!(
2057            matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
2058        );
2059        assert!(client
2060            .connect_realtime(&mut transport)
2061            .unwrap_err()
2062            .starts_with("sync.offline:"));
2063        assert!(client
2064            .set_presence(&mut transport, "project:p1", None)
2065            .unwrap_err()
2066            .starts_with("sync.offline:"));
2067        assert_eq!(transport.sync_calls, 0);
2068        assert_eq!(transport.realtime_sync_calls, 0);
2069        assert_eq!(transport.connects, 1);
2070        assert!(transport.messages.is_empty());
2071        assert!(matches!(
2072            client.drain_sync_intents().as_slice(),
2073            [SyncIntent::None]
2074        ));
2075        let pending = client.pending_commit_ids();
2076        drop(client);
2077        let mut reopened = SyncClient::open_path(
2078            "gate".into(),
2079            &schema,
2080            ClientLimits::default(),
2081            path.to_str().unwrap(),
2082        )
2083        .unwrap();
2084        assert!(reopened.transport_enabled());
2085        reopened.set_transport_enabled(&mut transport, false);
2086        assert_eq!(reopened.pending_commit_ids(), pending);
2087        assert_eq!(
2088            reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
2089            2
2090        );
2091        reopened.set_transport_enabled(&mut transport, true);
2092        assert!(matches!(
2093            reopened.drain_sync_intents().as_slice(),
2094            [SyncIntent::Interactive]
2095        ));
2096        reopened.set_transport_enabled(&mut transport, true);
2097        assert!(reopened.drain_sync_intents().is_empty());
2098        drop(reopened);
2099        std::fs::remove_file(path).unwrap();
2100    }
2101
2102    #[test]
2103    fn realtime_connection_ownership_is_idempotent() {
2104        let mut client = client();
2105        let mut transport = CountingRealtimeTransport {
2106            connects: 0,
2107            closes: 0,
2108            ..Default::default()
2109        };
2110        client
2111            .connect_realtime(&mut transport)
2112            .expect("first connect");
2113        client
2114            .connect_realtime(&mut transport)
2115            .expect("idempotent connect");
2116        assert_eq!(transport.connects, 1);
2117        client.disconnect_realtime(&mut transport);
2118        client.disconnect_realtime(&mut transport);
2119        assert_eq!(transport.closes, 1);
2120        client
2121            .connect_realtime(&mut transport)
2122            .expect("deliberate reconnect");
2123        assert_eq!(transport.connects, 2);
2124    }
2125
2126    #[test]
2127    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
2128        let mut client = client();
2129        client
2130            .set_realtime_policy(RealtimePolicy::Required)
2131            .expect("set policy");
2132        let mut transport = CountingRealtimeTransport::default();
2133        match client.sync(&mut transport) {
2134            SyncOutcome::RealtimeUnavailable {
2135                state,
2136                reason_code,
2137                retry_delay_ms,
2138            } => {
2139                assert_eq!(state, RealtimeState::Disconnected);
2140                assert_eq!(reason_code, None);
2141                assert_eq!(retry_delay_ms, 250);
2142            }
2143            other => panic!("expected RealtimeUnavailable, got {other:?}"),
2144        }
2145        assert_eq!(transport.sync_calls, 0);
2146        assert!(matches!(
2147            client.drain_sync_intents().as_slice(),
2148            [SyncIntent::Background { delay_ms: 250 }]
2149        ));
2150        let snapshot = client
2151            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2152            .expect("diagnostics");
2153        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2154        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
2155        assert_eq!(snapshot.host.realtime_reason_code, None);
2156        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2157        assert_eq!(
2158            snapshot
2159                .last_round
2160                .as_ref()
2161                .and_then(|round| round.error_code.clone()),
2162            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
2163        );
2164    }
2165
2166    #[test]
2167    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
2168        let mut client = client();
2169        client
2170            .set_realtime_policy(RealtimePolicy::Required)
2171            .expect("set policy");
2172        let mut transport = CountingRealtimeTransport {
2173            refuse_connect: true,
2174            ..Default::default()
2175        };
2176        assert!(client.connect_realtime(&mut transport).is_err());
2177        assert_eq!(client.realtime_state(), RealtimeState::Refused);
2178        let snapshot = client
2179            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2180            .expect("diagnostics");
2181        assert_eq!(
2182            snapshot.host.realtime_reason_code.as_deref(),
2183            Some("transport.failed")
2184        );
2185        match client.sync(&mut transport) {
2186            SyncOutcome::RealtimeUnavailable {
2187                state, reason_code, ..
2188            } => {
2189                assert_eq!(state, RealtimeState::Refused);
2190                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
2191            }
2192            other => panic!("expected RealtimeUnavailable, got {other:?}"),
2193        }
2194        assert_eq!(transport.sync_calls, 0);
2195        assert_eq!(transport.realtime_sync_calls, 0);
2196    }
2197
2198    #[test]
2199    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
2200        let mut client = client();
2201        client
2202            .set_realtime_policy(RealtimePolicy::Required)
2203            .expect("set policy");
2204        let mut transport = CountingRealtimeTransport::default();
2205        client.connect_realtime(&mut transport).expect("connect");
2206        assert_eq!(client.realtime_state(), RealtimeState::Connected);
2207        assert!(
2208            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
2209            "a policy change cannot silently re-route a connected socket"
2210        );
2211        assert!(matches!(
2212            client.sync(&mut transport),
2213            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
2214        ));
2215        assert_eq!(client.realtime_state(), RealtimeState::Lost);
2216        assert_eq!(transport.realtime_sync_calls, 1);
2217        assert_eq!(transport.sync_calls, 0);
2218        let snapshot = client
2219            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2220            .expect("diagnostics");
2221        assert_eq!(
2222            snapshot.host.realtime_reason_code.as_deref(),
2223            Some("sync.transport_failed")
2224        );
2225        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2226        match client.sync(&mut transport) {
2227            SyncOutcome::RealtimeUnavailable {
2228                state, reason_code, ..
2229            } => {
2230                assert_eq!(state, RealtimeState::Lost);
2231                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
2232            }
2233            other => panic!("expected RealtimeUnavailable, got {other:?}"),
2234        }
2235        assert_eq!(transport.sync_calls, 0);
2236        client.disconnect_realtime(&mut transport);
2237        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
2238        assert_eq!(transport.closes, 1);
2239        client.connect_realtime(&mut transport).expect("reconnect");
2240        assert_eq!(client.realtime_state(), RealtimeState::Connected);
2241        assert_eq!(transport.connects, 2);
2242    }
2243
2244    #[test]
2245    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
2246        let mut client = client();
2247        client
2248            .set_realtime_policy(RealtimePolicy::Off)
2249            .expect("set policy");
2250        let mut transport = CountingRealtimeTransport::default();
2251        assert!(client.connect_realtime(&mut transport).is_err());
2252        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
2253        assert!(matches!(
2254            client.sync(&mut transport),
2255            SyncOutcome::Failed { .. }
2256        ));
2257        assert_eq!(transport.sync_calls, 1);
2258        assert_eq!(transport.realtime_sync_calls, 0);
2259        let snapshot = client
2260            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2261            .expect("diagnostics");
2262        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
2263        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
2264    }
2265
2266    #[test]
2267    fn optional_policy_keeps_the_http_round() {
2268        let mut client = client();
2269        let mut transport = CountingRealtimeTransport::default();
2270        assert!(matches!(
2271            client.sync(&mut transport),
2272            SyncOutcome::Failed { .. }
2273        ));
2274        assert_eq!(transport.sync_calls, 1);
2275        assert_eq!(transport.realtime_sync_calls, 0);
2276        let snapshot = client
2277            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2278            .expect("diagnostics");
2279        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2280        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
2281    }
2282
2283    #[test]
2284    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
2285        let mut client = client();
2286        client
2287            .subscribe(
2288                "repair-tasks".to_owned(),
2289                "tasks".to_owned(),
2290                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2291                None,
2292            )
2293            .expect("subscribe");
2294        {
2295            let sub = client
2296                .subs
2297                .iter_mut()
2298                .find(|sub| sub.id == "repair-tasks")
2299                .expect("subscription");
2300            sub.cursor = 42;
2301            sub.synced_once = true;
2302            let persisted = sub.clone();
2303            client.persist_sub(&persisted).unwrap();
2304        }
2305        for table in ["_syncular_base_tasks", "tasks"] {
2306            client
2307                .conn
2308                .execute(
2309                    &format!(
2310                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
2311                    ),
2312                    rusqlite::params!["server-row", "p1"],
2313                )
2314                .expect("seed server row");
2315        }
2316        let pending = client
2317            .mutate(vec![Mutation::Upsert {
2318                table: "tasks".to_owned(),
2319                values: Map::from_iter([
2320                    ("id".to_owned(), Value::from("offline-row")),
2321                    ("project_id".to_owned(), Value::from("p1")),
2322                ]),
2323                base_version: None,
2324            }])
2325            .expect("queue offline work");
2326        client.drain_change_batches();
2327        client.drain_sync_intents();
2328
2329        assert_eq!(
2330            client
2331                .rebootstrap_local_data(&LocalDataRebootstrapInput {
2332                    rebootstrap_id: "support-case-001".to_owned(),
2333                })
2334                .expect("rebootstrap"),
2335            LocalDataRebootstrapResult {
2336                already_applied: false,
2337                retained_commits: 1,
2338                reset_subscriptions: 1,
2339            }
2340        );
2341        let visible_ids = client
2342            .conn
2343            .prepare("SELECT id FROM tasks ORDER BY id")
2344            .expect("prepare visible ids")
2345            .query_map([], |row| row.get::<_, String>(0))
2346            .expect("query visible ids")
2347            .collect::<Result<Vec<_>, _>>()
2348            .expect("collect visible ids");
2349        assert_eq!(visible_ids, vec!["offline-row"]);
2350        assert_eq!(client.pending_commit_ids(), vec![pending]);
2351        assert_eq!(
2352            client
2353                .subscription_state("repair-tasks")
2354                .expect("subscription")
2355                .cursor,
2356            -1
2357        );
2358        assert!(client.upgrading());
2359        assert!(client.sync_needed());
2360        assert!(matches!(
2361            client.drain_sync_intents().as_slice(),
2362            [SyncIntent::Interactive]
2363        ));
2364        assert_eq!(client.drain_change_batches().len(), 1);
2365
2366        assert_eq!(
2367            client
2368                .rebootstrap_local_data(&LocalDataRebootstrapInput {
2369                    rebootstrap_id: "support-case-001".to_owned(),
2370                })
2371                .expect("idempotent retry"),
2372            LocalDataRebootstrapResult {
2373                already_applied: true,
2374                retained_commits: 1,
2375                reset_subscriptions: 1,
2376            }
2377        );
2378        assert!(client.drain_change_batches().is_empty());
2379    }
2380
2381    #[test]
2382    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
2383        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
2384        assert_eq!(
2385            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
2386            (3, 4)
2387        );
2388        assert_eq!(
2389            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
2390            (0, 0)
2391        );
2392        for malformed in [
2393            "",
2394            "{}",
2395            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
2396            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
2397            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
2398        ] {
2399            assert_eq!(
2400                decode_local_rebootstrap_receipt(malformed)
2401                    .expect_err("malformed receipt must fail"),
2402                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2403            );
2404        }
2405    }
2406
2407    #[test]
2408    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
2409        let path = std::env::temp_dir().join(format!(
2410            "syncular-rebootstrap-receipt-{}.db",
2411            uuid::Uuid::new_v4()
2412        ));
2413        let schema = json!({
2414            "version": 1,
2415            "tables": [{
2416                "name": "tasks",
2417                "primaryKey": "id",
2418                "columns": [
2419                    { "name": "id", "type": "string", "nullable": false },
2420                    { "name": "project_id", "type": "string", "nullable": false }
2421                ],
2422                "scopes": [{ "pattern": "project:{project_id}" }]
2423            }]
2424        });
2425        let path_string = path.to_str().expect("UTF-8 temp path");
2426
2427        {
2428            let mut first = SyncClient::open_path(
2429                "repair-restart-client".to_owned(),
2430                &schema,
2431                ClientLimits::default(),
2432                path_string,
2433            )
2434            .expect("first open");
2435            first
2436                .subscribe(
2437                    "repair-tasks".to_owned(),
2438                    "tasks".to_owned(),
2439                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2440                    None,
2441                )
2442                .expect("subscribe");
2443            first
2444                .mutate(vec![Mutation::Upsert {
2445                    table: "tasks".to_owned(),
2446                    values: Map::from_iter([
2447                        ("id".to_owned(), Value::from("offline-row")),
2448                        ("project_id".to_owned(), Value::from("p1")),
2449                    ]),
2450                    base_version: None,
2451                }])
2452                .expect("queue offline work");
2453            assert_eq!(
2454                first
2455                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
2456                        rebootstrap_id: "restart-receipt".to_owned(),
2457                    })
2458                    .expect("first rebootstrap"),
2459                LocalDataRebootstrapResult {
2460                    already_applied: false,
2461                    retained_commits: 1,
2462                    reset_subscriptions: 1,
2463                }
2464            );
2465        }
2466
2467        let mut reopened = SyncClient::open_path(
2468            "repair-restart-client".to_owned(),
2469            &schema,
2470            ClientLimits::default(),
2471            path_string,
2472        )
2473        .expect("reopen");
2474        reopened.drain_change_batches();
2475        reopened.drain_sync_intents();
2476        assert_eq!(
2477            reopened
2478                .rebootstrap_local_data(&LocalDataRebootstrapInput {
2479                    rebootstrap_id: "restart-receipt".to_owned(),
2480                })
2481                .expect("receipt replay"),
2482            LocalDataRebootstrapResult {
2483                already_applied: true,
2484                retained_commits: 1,
2485                reset_subscriptions: 1,
2486            }
2487        );
2488        assert!(reopened.drain_change_batches().is_empty());
2489        assert!(reopened.drain_sync_intents().is_empty());
2490        drop(reopened);
2491        std::fs::remove_file(path).expect("remove temp database");
2492    }
2493
2494    #[test]
2495    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
2496        let mut malformed = client();
2497        malformed
2498            .subscribe(
2499                "repair-tasks".to_owned(),
2500                "tasks".to_owned(),
2501                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2502                None,
2503            )
2504            .expect("subscribe");
2505        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
2506        malformed.drain_change_batches();
2507        malformed.drain_sync_intents();
2508        let malformed_error = malformed
2509            .rebootstrap_local_data(&LocalDataRebootstrapInput {
2510                rebootstrap_id: "malformed".to_owned(),
2511            })
2512            .expect_err("malformed receipt must fail closed");
2513        assert_eq!(
2514            malformed_error,
2515            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2516        );
2517        assert!(!malformed.upgrading());
2518        assert_eq!(
2519            malformed
2520                .subscription_state("repair-tasks")
2521                .expect("unchanged subscription")
2522                .cursor,
2523            -1
2524        );
2525        assert!(malformed.drain_change_batches().is_empty());
2526        assert!(malformed.drain_sync_intents().is_empty());
2527
2528        let mut unreadable = client();
2529        unreadable
2530            .conn
2531            .execute(
2532                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
2533                rusqlite::params!["server-row", "p1"],
2534            )
2535            .expect("seed visible row");
2536        unreadable
2537            .conn
2538            .execute("DROP TABLE _syncular_meta", [])
2539            .expect("break marker storage");
2540        let unreadable_error = unreadable
2541            .rebootstrap_local_data(&LocalDataRebootstrapInput {
2542                rebootstrap_id: "unreadable".to_owned(),
2543            })
2544            .expect_err("unreadable marker storage must fail closed");
2545        assert_eq!(
2546            unreadable_error,
2547            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
2548        );
2549        let visible_rows = unreadable
2550            .conn
2551            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
2552            .expect("visible projection remains");
2553        assert_eq!(visible_rows, 1);
2554        assert!(!unreadable.upgrading());
2555        assert!(unreadable.drain_change_batches().is_empty());
2556        assert!(unreadable.drain_sync_intents().is_empty());
2557    }
2558
2559    #[test]
2560    fn local_rebootstrap_cannot_bypass_schema_floor() {
2561        let mut client = client();
2562        client.set_schema_floor(Some(SchemaFloor {
2563            required_schema_version: Some(2),
2564            latest_schema_version: Some(2),
2565        }));
2566        let error = client
2567            .rebootstrap_local_data(&LocalDataRebootstrapInput {
2568                rebootstrap_id: "blocked-floor".to_owned(),
2569            })
2570            .expect_err("schema floor must block repair");
2571        assert!(error.contains("cannot bypass an active schema-floor stop"));
2572    }
2573
2574    #[test]
2575    fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
2576        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2577            "columns":[{"name":"id","type":"string","nullable":false},
2578                {"name":"project_id","type":"string","nullable":false},
2579                {"name":"starts_at_ms","type":"integer","nullable":false},
2580                {"name":"version","type":"integer","nullable":false}],
2581            "scopes":[{"pattern":"project:{project_id}"}]}]});
2582        let mut client =
2583            SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
2584        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2585        client
2586            .write_base_row(
2587                "tasks",
2588                &vec![
2589                    Some(ColumnValue::String("t1".into())),
2590                    Some(ColumnValue::String("p1".into())),
2591                    Some(ColumnValue::Integer(100)),
2592                    Some(ColumnValue::Integer(1)),
2593                ],
2594                1,
2595            )
2596            .unwrap();
2597        client.rebuild_overlay().expect("rebuild overlay");
2598        let first = client
2599            .patch(
2600                "tasks",
2601                "t1",
2602                Map::from_iter([
2603                    ("starts_at_ms".into(), json!(200)),
2604                    ("version".into(), json!(2)),
2605                ]),
2606                None,
2607            )
2608            .unwrap();
2609        let (_, first_request) = client.build_request(false).unwrap();
2610        // This second edit happens while the first request is in flight.
2611        let second = client
2612            .patch(
2613                "tasks",
2614                "t1",
2615                Map::from_iter([
2616                    ("starts_at_ms".into(), json!(300)),
2617                    ("version".into(), json!(3)),
2618                ]),
2619                None,
2620            )
2621            .unwrap();
2622        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2623        for (id, seq, meta) in [
2624            (first, 2, first_request),
2625            (second, 3, client.build_request(false).unwrap().1),
2626        ] {
2627            let response = Message {
2628                wire_version: WIRE_VERSION,
2629                msg_kind: MsgKind::Response,
2630                frames: vec![
2631                    Frame::RespHeader {
2632                        required_schema_version: None,
2633                        latest_schema_version: None,
2634                        log_epoch: Some("epoch-1".into()),
2635                        reset_required: Some(false),
2636                    },
2637                    Frame::PushResult {
2638                        client_commit_id: id,
2639                        status: PushStatus::Applied,
2640                        commit_seq: Some(seq),
2641                        results: vec![OpResult::Applied { op_index: 0 }],
2642                    },
2643                ],
2644            };
2645            assert!(matches!(
2646                client.process_response(&mut transport, response, &meta),
2647                SyncOutcome::Ok(_)
2648            ));
2649            let row = &client
2650                .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
2651                .unwrap()[0];
2652            assert_eq!(
2653                row["starts_at_ms"],
2654                json!(300),
2655                "an applied ACK without a pull image must not expose the old base"
2656            );
2657            assert_eq!(
2658                row["version"],
2659                json!(3),
2660                "the queued domain version must remain monotonic"
2661            );
2662        }
2663    }
2664
2665    #[test]
2666    fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
2667        for stored in [None, Some("epoch-old")] {
2668            let mut client = client();
2669            client
2670                .subscribe(
2671                    "tasks".into(),
2672                    "tasks".into(),
2673                    vec![("project_id".into(), vec!["p1".into()])],
2674                    None,
2675                )
2676                .unwrap();
2677            client
2678                .conn
2679                .execute_batch(
2680                    "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
2681                )
2682                .unwrap();
2683            let id = client
2684                .mutate(vec![Mutation::Upsert {
2685                    table: "tasks".into(),
2686                    values: Map::from_iter([
2687                        ("id".into(), json!("offline")),
2688                        ("project_id".into(), json!("p1")),
2689                    ]),
2690                    base_version: None,
2691                }])
2692                .unwrap();
2693            if let Some(epoch) = stored {
2694                client.set_meta(LOG_EPOCH_KEY, epoch);
2695            }
2696            let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
2697            let (_, meta) = client.build_request(false).unwrap();
2698            client.drain_change_batches();
2699            client.drain_sync_intents();
2700            let response = Message {
2701                wire_version: WIRE_VERSION,
2702                msg_kind: MsgKind::Response,
2703                frames: vec![Frame::RespHeader {
2704                    required_schema_version: None,
2705                    latest_schema_version: None,
2706                    log_epoch: Some("epoch-new".into()),
2707                    reset_required: Some(true),
2708                }],
2709            };
2710            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2711            let outcome = client.process_response(&mut transport, response, &meta);
2712            let SyncOutcome::Ok(report) = outcome else {
2713                panic!("epoch round failed: {outcome:?}");
2714            };
2715            assert_eq!(
2716                report.resets,
2717                if stored.is_none() {
2718                    Vec::<String>::new()
2719                } else {
2720                    vec!["tasks".into()]
2721                }
2722            );
2723            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
2724            assert_eq!(client.pending_commit_ids(), vec![id]);
2725            assert_eq!(
2726                client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2727                json!("offline")
2728            );
2729            let trigger: i64 = client
2730                .conn
2731                .query_row(
2732                    "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
2733                    [],
2734                    |row| row.get(0),
2735                )
2736                .unwrap();
2737            if stored.is_none() {
2738                assert!(!client.upgrading);
2739                assert!(client
2740                    .drain_change_batches()
2741                    .iter()
2742                    .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
2743                assert_eq!(
2744                    serde_json::to_value(client.subscription_state("tasks")).unwrap(),
2745                    before
2746                );
2747                assert_eq!(trigger, 1, "acquisition must not drop the synced table");
2748            } else {
2749                assert!(client.upgrading);
2750                assert_eq!(trigger, 0, "an epoch change must drop the old table");
2751            }
2752            assert!(matches!(
2753                client.drain_sync_intents().as_slice(),
2754                [SyncIntent::Interactive]
2755            ));
2756        }
2757    }
2758
2759    #[test]
2760    fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
2761        for key in ["logEpoch", "localRevision"] {
2762            let mut client = client();
2763            let id = client
2764                .mutate(vec![Mutation::Upsert {
2765                    table: "tasks".into(),
2766                    values: Map::from_iter([
2767                        ("id".into(), json!("offline")),
2768                        ("project_id".into(), json!("p1")),
2769                    ]),
2770                    base_version: None,
2771                }])
2772                .unwrap();
2773            let revision = client.local_revision();
2774            let needed = client.sync_needed;
2775            client.drain_change_batches();
2776            client.drain_sync_intents();
2777            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();
2778            assert!(client.run_log_epoch_reset("epoch-new").is_err());
2779            assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
2780            assert!(!client.upgrading);
2781            assert_eq!(client.sync_needed, needed);
2782            assert_eq!(client.local_revision(), revision);
2783            assert_eq!(client.pending_commit_ids(), [id]);
2784            assert!(client.drain_change_batches().is_empty());
2785            assert!(client.drain_sync_intents().is_empty());
2786        }
2787    }
2788
2789    #[test]
2790    fn failed_log_epoch_resets_restore_memory_and_durable_state() {
2791        for key in [
2792            "localSchemaVersion",
2793            "localSchemaDescriptor",
2794            "logEpoch",
2795            "localRevision",
2796        ] {
2797            let mut client = client();
2798            client.set_meta(LOG_EPOCH_KEY, "epoch-old");
2799            let commit = client
2800                .mutate(vec![Mutation::Upsert {
2801                    table: "tasks".into(),
2802                    values: Map::from_iter([
2803                        ("id".into(), json!("offline")),
2804                        ("project_id".into(), json!("p1")),
2805                    ]),
2806                    base_version: None,
2807                }])
2808                .unwrap();
2809            client.active_round = Some(uuid::Uuid::new_v4());
2810            client.overlay_dirty.set(false);
2811            let round = client.active_round;
2812            let needed = client.sync_needed;
2813            let revision = client.local_revision();
2814            let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
2815            client.drain_change_batches();
2816            client.drain_sync_intents();
2817            client.conn.execute_batch(&format!("CREATE TRIGGER fail_reset BEFORE INSERT ON _syncular_meta WHEN NEW.key='{key}' BEGIN SELECT RAISE(FAIL,'injected reset failure'); END")).unwrap();
2818            assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
2819            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
2820            assert!(!client.upgrading);
2821            assert_eq!(client.active_round, round);
2822            assert!(!client.overlay_dirty.get());
2823            assert_eq!(client.sync_needed, needed);
2824            assert_eq!(client.local_revision(), revision);
2825            assert_eq!(
2826                client.pending_commit_ids().as_slice(),
2827                std::slice::from_ref(&commit)
2828            );
2829            assert_eq!(
2830                serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
2831                rows
2832            );
2833            assert!(client.drain_change_batches().is_empty());
2834            assert!(client.drain_sync_intents().is_empty());
2835            assert!(client.conn.is_autocommit());
2836            client
2837                .conn
2838                .execute_batch("DROP TRIGGER fail_reset")
2839                .unwrap();
2840            client.run_log_epoch_reset("epoch-new").unwrap();
2841            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
2842            assert_eq!(client.pending_commit_ids(), [commit]);
2843        }
2844    }
2845
2846    #[test]
2847    fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
2848        for subscriptions in [0, 2, 6, 12] {
2849            let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
2850                { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
2851                { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
2852            ] }), ClientLimits::default()).unwrap();
2853            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2854            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();
2855            for index in 0..32 {
2856                client
2857                    .mutate(vec![Mutation::Upsert {
2858                        table: "tasks".into(),
2859                        values: Map::from_iter([
2860                            ("id".into(), json!(format!("task-{index}"))),
2861                            ("project_id".into(), json!("p1")),
2862                        ]),
2863                        base_version: None,
2864                    }])
2865                    .unwrap();
2866            }
2867            let (_, meta) = client.build_request(false).unwrap();
2868            let mut frames = vec![Frame::RespHeader {
2869                required_schema_version: None,
2870                latest_schema_version: None,
2871                log_epoch: Some("epoch-1".into()),
2872                reset_required: Some(false),
2873            }];
2874            frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
2875                Frame::PushResult {
2876                    client_commit_id: id.clone(),
2877                    status: PushStatus::Applied,
2878                    commit_seq: Some(index as i64 + 1),
2879                    results: vec![OpResult::Applied { op_index: 0 }],
2880                }
2881            }));
2882            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2883            client.acknowledged_replay_count.set(0);
2884            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2885            assert!(matches!(
2886                client.process_response(
2887                    &mut transport,
2888                    Message {
2889                        wire_version: WIRE_VERSION,
2890                        msg_kind: MsgKind::Response,
2891                        frames
2892                    },
2893                    &meta
2894                ),
2895                SyncOutcome::Ok(_)
2896            ));
2897            assert_eq!(client.acknowledged_replay_count.get(), 32);
2898            assert_eq!(
2899                client
2900                    .query(
2901                        "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
2902                        &[]
2903                    )
2904                    .unwrap()[0]["n"],
2905                json!(32)
2906            );
2907            assert_eq!(
2908                client
2909                    .query(
2910                        "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
2911                        &[]
2912                    )
2913                    .unwrap()[0]["n"],
2914                json!(32)
2915            );
2916            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2917            client.acknowledged_replay_count.set(0);
2918            for index in 0..subscriptions {
2919                client
2920                    .subscribe(
2921                        format!("docs-{index}"),
2922                        "docs".into(),
2923                        vec![("project_id".into(), vec![format!("scope-{index}")])],
2924                        None,
2925                    )
2926                    .unwrap();
2927                let sub = client.subs.len() - 1;
2928                client.subs[sub].effective =
2929                    Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
2930                let segment = RowsSegment {
2931                    table: "docs".into(),
2932                    schema_version: 1,
2933                    columns: client.schema.table("docs").unwrap().wire_columns.clone(),
2934                    blocks: if index % 2 == 0 {
2935                        vec![vec![ssp2::segment::SegmentRow {
2936                            values: vec![
2937                                Some(ColumnValue::String(format!("doc-{index}"))),
2938                                Some(ColumnValue::String(format!("scope-{index}"))),
2939                            ],
2940                            server_version: 1,
2941                        }]]
2942                    } else {
2943                        vec![]
2944                    },
2945                };
2946                assert!(client.apply_segment(sub, &segment, true).is_ok());
2947            }
2948            assert_eq!(
2949                client
2950                    .query("SELECT count(*) AS n FROM ack_writes", &[])
2951                    .unwrap()[0]["n"],
2952                json!(0),
2953                "unrelated bootstrap must not restore/replay tasks"
2954            );
2955            assert_eq!(
2956                client.acknowledged_replay_count.get(),
2957                0,
2958                "unrelated bootstrap must not decode task ACKs"
2959            );
2960            assert_eq!(
2961                client
2962                    .query("SELECT count(*) AS n FROM tasks", &[])
2963                    .unwrap()[0]["n"],
2964                json!(32)
2965            );
2966            assert_eq!(
2967                client
2968                    .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
2969                    .unwrap()[0]["n"],
2970                json!(32)
2971            );
2972        }
2973    }
2974
2975    #[test]
2976    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
2977        let mut client = client();
2978        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2979        const COMMIT_COUNT: usize = 32;
2980
2981        for index in 0..COMMIT_COUNT {
2982            client
2983                .mutate(vec![Mutation::Upsert {
2984                    table: "tasks".to_owned(),
2985                    values: Map::from_iter([
2986                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2987                        ("project_id".to_owned(), Value::from("project-1")),
2988                    ]),
2989                    base_version: None,
2990                }])
2991                .expect("queue commit");
2992        }
2993
2994        let (_, request_meta) = client.build_request(false).unwrap();
2995        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
2996        let mut frames = vec![Frame::RespHeader {
2997            required_schema_version: None,
2998            latest_schema_version: None,
2999            log_epoch: Some("epoch-1".to_owned()),
3000            reset_required: Some(false),
3001        }];
3002        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
3003            |(index, client_commit_id)| Frame::PushResult {
3004                client_commit_id: client_commit_id.clone(),
3005                status: PushStatus::Applied,
3006                commit_seq: Some(index as i64 + 1),
3007                results: vec![OpResult::Applied { op_index: 0 }],
3008            },
3009        ));
3010        let response = Message {
3011            wire_version: WIRE_VERSION,
3012            msg_kind: MsgKind::Response,
3013            frames,
3014        };
3015        let mut transport =
3016            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3017
3018        client.overlay_rebuild_count.set(0);
3019        client.outcome_prune_count.set(0);
3020        let outcome = client.process_response(&mut transport, response, &request_meta);
3021        assert!(matches!(outcome, SyncOutcome::Ok(_)));
3022        assert!(client.pending_commit_ids().is_empty());
3023        assert_eq!(
3024            client.overlay_rebuild_count.get(),
3025            1,
3026            "one response must reconcile its acknowledged commits with one overlay rebuild"
3027        );
3028        assert_eq!(
3029            client.outcome_prune_count.get(),
3030            1,
3031            "one response must enforce outcome retention once"
3032        );
3033    }
3034
3035    #[test]
3036    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
3037        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
3038            "columns": [
3039                {"name":"id", "type":"string", "nullable":false},
3040                {"name":"project_id", "type":"string", "nullable":false},
3041                {"name":"title", "type":"string", "nullable":false}
3042            ], "scopes": [{"pattern":"project:{project_id}"}],
3043            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3044            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
3045        }] });
3046        let mut incremental =
3047            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3048        let mut reference =
3049            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3050        let tables = vec!["tasks".to_owned()];
3051        for index in 0..32 {
3052            let id = format!("task-{}", index % 8);
3053            let values = Map::from_iter([
3054                ("id".to_owned(), Value::from(id.clone())),
3055                (
3056                    "project_id".to_owned(),
3057                    Value::from(format!("p{}", index % 2)),
3058                ),
3059                ("title".to_owned(), Value::from(format!("needle{index}"))),
3060            ]);
3061            let payload = encode_row_json(
3062                incremental.schema.table("tasks").unwrap(),
3063                &id,
3064                &values,
3065                &incremental.encryption,
3066            )
3067            .unwrap();
3068            let change = ssp2::model::Change {
3069                table_index: 0,
3070                row_id: id,
3071                op: if index % 5 == 0 {
3072                    Op::Delete
3073                } else {
3074                    Op::Upsert
3075                },
3076                row_version: Some(index + 1),
3077                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
3078                row: Some(payload),
3079            };
3080            reference.overlay_dirty.set(true);
3081            for instance in [&mut incremental, &mut reference] {
3082                instance.begin_observation("mirror_test").unwrap();
3083                let mut batch = ChangeAccumulator::default();
3084                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
3085                instance
3086                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
3087                    .unwrap();
3088                instance
3089                    .rebuild_overlay_if_dirty()
3090                    .expect("rebuild overlay");
3091                instance.finish_observation("mirror_test", batch).unwrap();
3092            }
3093            for sql in [
3094                "SELECT * FROM tasks ORDER BY id",
3095                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3096            ] {
3097                assert_eq!(
3098                    incremental.query(sql, &[]).unwrap(),
3099                    reference.query(sql, &[]).unwrap()
3100                );
3101            }
3102            assert_eq!(incremental.local_revision(), reference.local_revision());
3103            assert_eq!(
3104                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3105                serde_json::to_value(reference.drain_change_batches()).unwrap()
3106            );
3107        }
3108        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
3109        assert_eq!(reference.overlay_rebuild_count.get(), 32);
3110    }
3111
3112    #[test]
3113    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
3114        let cases = [
3115            (
3116                "string",
3117                vec![
3118                    json!("1"),
3119                    json!("01"),
3120                    json!(""),
3121                    json!("nul\u{0}id"),
3122                    json!("é"),
3123                ],
3124            ),
3125            (
3126                "integer",
3127                vec![
3128                    json!(i64::MIN),
3129                    json!(-1),
3130                    json!(0),
3131                    json!(1),
3132                    json!(i64::MAX),
3133                ],
3134            ),
3135            ("boolean", vec![json!(false), json!(true)]),
3136            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
3137        ];
3138        for (kind, ids) in cases {
3139            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3140                "columns":[{"name":"id","type":kind,"nullable":false},
3141                    {"name":"project_id","type":"string","nullable":false}],
3142                "scopes":[{"pattern":"project:{project_id}"}]}]});
3143            let mut client =
3144                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
3145            for (index, id) in ids.iter().enumerate() {
3146                let table = client.schema.table("tasks").unwrap();
3147                client
3148                    .write_base_row(
3149                        "tasks",
3150                        &vec![
3151                            if kind == "integer" {
3152                                // Exercise local SQLite key lookup across its
3153                                // full i64 range independently of authoring.
3154                                Some(ColumnValue::Integer(id.as_i64().unwrap()))
3155                            } else {
3156                                json_to_column_value(&table.columns[0], Some(id)).unwrap()
3157                            },
3158                            Some(ColumnValue::String(format!("p{index}"))),
3159                        ],
3160                        1,
3161                    )
3162                    .unwrap();
3163            }
3164            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3165            for base in [true, false] {
3166                let full = if base {
3167                    base_table("tasks")
3168                } else {
3169                    visible_table("tasks")
3170                };
3171                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
3172                for row_id in [
3173                    "1",
3174                    "01",
3175                    "1.0",
3176                    "1.0000000000000002",
3177                    "+1",
3178                    "1e0",
3179                    " 1",
3180                    "-1",
3181                    "0",
3182                    "9223372036854775807",
3183                    "-9223372036854775808",
3184                    "true",
3185                    "false",
3186                    "",
3187                    "nul\u{0}id",
3188                    "é",
3189                    "null",
3190                    "{\"a\":1}",
3191                ] {
3192                    let old_sql =
3193                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
3194                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
3195                    let old = client
3196                        .conn
3197                        .prepare(&old_sql)
3198                        .unwrap()
3199                        .query_map([row_id], |row| row.get::<_, String>(0))
3200                        .unwrap()
3201                        .collect::<Result<Vec<_>, _>>()
3202                        .unwrap();
3203                    let new = client
3204                        .conn
3205                        .prepare_cached(&new_sql)
3206                        .unwrap()
3207                        .query_map([row_id], |row| row.get::<_, String>(0))
3208                        .unwrap()
3209                        .collect::<Result<Vec<_>, _>>()
3210                        .unwrap();
3211                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
3212                    let mut batch = ChangeAccumulator::default();
3213                    assert_eq!(
3214                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
3215                        !old.is_empty()
3216                    );
3217                    let expected = old.first().map(|scope| {
3218                        BTreeMap::from([(
3219                            "tasks".to_owned(),
3220                            Some(BTreeSet::from([format!("project:{scope}")])),
3221                        )])
3222                    });
3223                    assert_eq!(batch.tables, expected.unwrap_or_default());
3224                    client
3225                        .conn
3226                        .execute_batch("SAVEPOINT lookup_delete")
3227                        .unwrap();
3228                    if base {
3229                        client.delete_base_row("tasks", row_id).unwrap();
3230                    } else {
3231                        client
3232                            .apply_outbox_ops(&[OutboxOp {
3233                                upsert: false,
3234                                table: "tasks".into(),
3235                                row_id: row_id.into(),
3236                                base_version: None,
3237                                values: None,
3238                            }])
3239                            .expect("replay delete");
3240                    }
3241                    let remaining: i64 = client
3242                        .conn
3243                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
3244                            row.get(0)
3245                        })
3246                        .unwrap();
3247                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
3248                    client
3249                        .conn
3250                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
3251                        .unwrap();
3252                    client.overlay_dirty.set(false);
3253                }
3254                for (predicate, expected) in [
3255                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
3256                    (predicate, "SEARCH"),
3257                ] {
3258                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
3259                    let details = client
3260                        .conn
3261                        .prepare(&sql)
3262                        .unwrap()
3263                        .query_map(["1"], |row| row.get::<_, String>(3))
3264                        .unwrap()
3265                        .collect::<Result<Vec<_>, _>>()
3266                        .unwrap();
3267                    assert!(
3268                        details.iter().any(|detail| detail.contains(expected)),
3269                        "{kind}: {details:?}"
3270                    );
3271                }
3272            }
3273        }
3274    }
3275
3276    #[test]
3277    fn primary_keys_reject_every_ineligible_column_type() {
3278        // §2.4: a `rowId` is a string, so a primary key type whose string form
3279        // differs per renderer, or cannot be reproduced by local storage
3280        // comparison, is a schema error rather than a silent lookup miss.
3281        for ty in ["float", "bytes", "crdt", "blob_ref"] {
3282            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3283                "columns":[{"name":"id","type":ty,"nullable":false},
3284                    {"name":"project_id","type":"string","nullable":false}],
3285                "scopes":[{"pattern":"project:{project_id}"}]}]});
3286            let error =
3287                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
3288                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
3289                    Err(error) => error,
3290                };
3291            assert!(
3292                error.contains("has an ineligible column type (§2.4)"),
3293                "{ty}: {error}"
3294            );
3295        }
3296        for ty in ["string", "integer", "boolean", "json"] {
3297            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3298                "columns":[{"name":"id","type":ty,"nullable":false},
3299                    {"name":"project_id","type":"string","nullable":false}],
3300                "scopes":[{"pattern":"project:{project_id}"}]}]});
3301            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
3302                .unwrap_or_else(|error| panic!("{ty}: {error}"));
3303        }
3304    }
3305
3306    #[test]
3307    fn pending_row_reconciliation_matches_full_fifo_replay() {
3308        for unique in [false, true] {
3309            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
3310                "name": name, "primaryKey": "id", "columns": [
3311                    {"name":"id", "type":"string", "nullable":false},
3312                    {"name":"project_id", "type":"string", "nullable":false},
3313                    {"name":"title", "type":"string", "nullable":false}],
3314                "scopes": [{"pattern":"project:{project_id}"}],
3315                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
3316                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
3317            })))});
3318            let mut incremental =
3319                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
3320            let mut reference =
3321                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
3322            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
3323            for instance in [&mut incremental, &mut reference] {
3324                for table in &tables {
3325                    for index in 0..12 {
3326                        instance
3327                            .write_base_row(
3328                                table,
3329                                &vec![
3330                                    Some(ColumnValue::String(format!("row{index}"))),
3331                                    Some(ColumnValue::String("p0".into())),
3332                                    Some(ColumnValue::String(format!("base{index}"))),
3333                                ],
3334                                1,
3335                            )
3336                            .unwrap();
3337                    }
3338                }
3339                instance
3340                    .rebuild_overlay_if_dirty()
3341                    .expect("rebuild overlay");
3342            }
3343            for index in 0..80 {
3344                let mutations = (0..3)
3345                    .map(|offset| {
3346                        let table = tables[(index + offset) % 2].clone();
3347                        let id = format!("row{}", (index + offset) % 16);
3348                        if (index + offset) % 7 == 0 {
3349                            Mutation::Delete {
3350                                table,
3351                                row_id: id,
3352                                base_version: None,
3353                            }
3354                        } else {
3355                            Mutation::Upsert {
3356                                table,
3357                                values: Map::from_iter([
3358                                    ("id".into(), Value::from(id)),
3359                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
3360                                    (
3361                                        "title".into(),
3362                                        Value::from(format!("needle{}", (index + offset) % 16)),
3363                                    ),
3364                                ]),
3365                                base_version: None,
3366                            }
3367                        }
3368                    })
3369                    .collect::<Vec<_>>();
3370                for instance in [&mut incremental, &mut reference] {
3371                    instance.mutate(mutations.clone()).unwrap();
3372                }
3373                // Most frames touch only the table without unique constraints.
3374                // Mixed frames must retain full replay for both tables.
3375                let changes = (0..3)
3376                    .map(|offset| {
3377                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
3378                        let id = format!("row{}", (index + offset) % 16);
3379                        let values = Map::from_iter([
3380                            ("id".into(), Value::from(id.clone())),
3381                            (
3382                                "project_id".into(),
3383                                Value::from(format!("p{}", (index + 1) % 3)),
3384                            ),
3385                            (
3386                                "title".into(),
3387                                Value::from(format!("remote{index}_{offset}")),
3388                            ),
3389                        ]);
3390                        ssp2::model::Change {
3391                            table_index: table_index as u16,
3392                            row_id: id.clone(),
3393                            op: if (index + offset) % 5 == 0 {
3394                                Op::Delete
3395                            } else {
3396                                Op::Upsert
3397                            },
3398                            row_version: Some(index as i64 + 2),
3399                            scopes: vec![],
3400                            row: Some(
3401                                encode_row_json(
3402                                    incremental.schema.table(&tables[table_index]).unwrap(),
3403                                    &id,
3404                                    &values,
3405                                    &incremental.encryption,
3406                                )
3407                                .unwrap(),
3408                            ),
3409                        }
3410                    })
3411                    .collect::<Vec<_>>();
3412                reference.overlay_dirty.set(true);
3413                incremental
3414                    .apply_commit_frame(&tables, &changes, None)
3415                    .unwrap();
3416                reference
3417                    .apply_commit_frame(&tables, &changes, None)
3418                    .unwrap();
3419                for table in &tables {
3420                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
3421                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
3422                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
3423                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
3424                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
3425                    }
3426                }
3427                assert_eq!(incremental.local_revision(), reference.local_revision());
3428                assert_eq!(
3429                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3430                    serde_json::to_value(reference.drain_change_batches()).unwrap()
3431                );
3432            }
3433            assert_eq!(
3434                incremental.overlay_rebuild_count.get(),
3435                if unique { 81 } else { 21 }
3436            );
3437            assert_eq!(reference.overlay_rebuild_count.get(), 81);
3438        }
3439    }
3440
3441    #[test]
3442    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
3443        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
3444            "columns":[{"name":"id", "type":"string", "nullable":false},
3445                {"name":"title", "type":"string", "nullable":false}],
3446            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
3447        let mut client =
3448            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
3449        for (id, title) in [("a", "original"), ("b", "occupied")] {
3450            client
3451                .write_base_row(
3452                    "tasks",
3453                    &vec![
3454                        Some(ColumnValue::String(id.into())),
3455                        Some(ColumnValue::String(title.into())),
3456                    ],
3457                    1,
3458                )
3459                .unwrap();
3460        }
3461        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3462        let pending = client
3463            .mutate(vec![Mutation::Upsert {
3464                table: "tasks".into(),
3465                values: Map::from_iter([
3466                    ("id".into(), Value::from("a")),
3467                    ("title".into(), Value::from("available")),
3468                ]),
3469                base_version: None,
3470            }])
3471            .unwrap();
3472        assert_eq!(
3473            client
3474                .conn
3475                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
3476                    .get::<_, String>(
3477                    0
3478                ))
3479                .unwrap(),
3480            "available"
3481        );
3482        for (title, expected) in [("available", "original"), ("occupied", "available")] {
3483            let change = ssp2::model::Change {
3484                table_index: 0,
3485                row_id: "b".into(),
3486                op: Op::Upsert,
3487                row_version: Some(2),
3488                scopes: vec![],
3489                row: Some(
3490                    encode_row_json(
3491                        client.schema.table("tasks").unwrap(),
3492                        "b",
3493                        &Map::from_iter([
3494                            ("id".into(), Value::from("b")),
3495                            ("title".into(), Value::from(title)),
3496                        ]),
3497                        &client.encryption,
3498                    )
3499                    .unwrap(),
3500                ),
3501            };
3502            client
3503                .apply_commit_frame(&["tasks".into()], &[change], None)
3504                .unwrap();
3505            assert_eq!(
3506                client
3507                    .conn
3508                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
3509                        .get::<_, String>(
3510                        0
3511                    ))
3512                    .unwrap(),
3513                expected
3514            );
3515            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
3516        }
3517        assert_eq!(client.overlay_rebuild_count.get(), 3);
3518    }
3519
3520    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
3521        let schema = json!({ "version": 1, "tables": [
3522            { "name":"tasks", "primaryKey":"id", "columns":[
3523                {"name":"id","type":"string","nullable":false},
3524                {"name":"project_id","type":"string","nullable":false}],
3525                "scopes":[{"pattern":"project:{project_id}"}] },
3526            { "name":"catalogue", "primaryKey":"id", "columns":[
3527                {"name":"id","type":"string","nullable":false},
3528                {"name":"project_id","type":"string","nullable":false},
3529                {"name":"title","type":"string","nullable":false}],
3530                "scopes":[{"pattern":"project:{project_id}"}],
3531                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
3532        ] });
3533        let mut client =
3534            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
3535        if unchanged_rows > 0 {
3536            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();
3537        }
3538        client.rebuild_overlay().expect("rebuild overlay");
3539        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();
3540        let mut elapsed = Vec::new();
3541        for round in 1..=rounds {
3542            let pending = client
3543                .mutate(vec![Mutation::Upsert {
3544                    table: "tasks".into(),
3545                    values: Map::from_iter([
3546                        ("id".into(), json!("t1")),
3547                        ("project_id".into(), json!("p1")),
3548                    ]),
3549                    base_version: None,
3550                }])
3551                .unwrap();
3552            let started = std::time::Instant::now();
3553            client
3554                .write_base_row(
3555                    "tasks",
3556                    &vec![
3557                        Some(ColumnValue::String("t1".into())),
3558                        Some(ColumnValue::String("p1".into())),
3559                    ],
3560                    round,
3561                )
3562                .unwrap();
3563            let mut report = SyncReport::default();
3564            client
3565                .handle_push_results(
3566                    &[Frame::PushResult {
3567                        client_commit_id: pending.clone(),
3568                        status: PushStatus::Applied,
3569                        commit_seq: Some(round),
3570                        results: vec![OpResult::Applied { op_index: 0 }],
3571                    }],
3572                    &HashSet::from([pending.as_str()]),
3573                    &HashMap::new(),
3574                    &mut report,
3575                    Some(&pending),
3576                )
3577                .unwrap();
3578            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3579            elapsed.push(started.elapsed().as_nanos());
3580        }
3581        assert_eq!(
3582            client
3583                .conn
3584                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
3585                .unwrap(),
3586            0,
3587            "unchanged catalogue must never be copied"
3588        );
3589        assert_eq!(
3590            client
3591                .conn
3592                .query_row(
3593                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
3594                    [],
3595                    |r| r.get::<_, i64>(0)
3596                )
3597                .unwrap(),
3598            unchanged_rows
3599        );
3600        assert_eq!(
3601            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
3602            json!(rounds)
3603        );
3604        elapsed
3605    }
3606
3607    #[test]
3608    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
3609        let elapsed = measure_scoped_overlay(100_000, 1);
3610        eprintln!(
3611            "table-scoped overlay with 100k unchanged rows: {} ns",
3612            elapsed[0]
3613        );
3614    }
3615
3616    #[test]
3617    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
3618    fn benchmark_scoped_overlay() {
3619        for rows in [0, 100_000, 200_000] {
3620            let mut elapsed = measure_scoped_overlay(rows, 30);
3621            elapsed.sort_unstable();
3622            eprintln!(
3623                "{}",
3624                json!({"unchangedRows": rows, "rounds": elapsed.len(),
3625                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
3626                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
3627            );
3628        }
3629    }
3630
3631    #[test]
3632    fn released_context_discards_in_flight_ack_and_server_rows() {
3633        for barrier in [
3634            "preflight",
3635            "purge",
3636            "rebootstrap",
3637            "subscription",
3638            "failed-purge-replay",
3639            "failed-purge-publish",
3640            "failed-rebootstrap",
3641        ] {
3642            let mut client = client();
3643            client.set_meta(LOG_EPOCH_KEY, "epoch");
3644            client
3645                .subscribe(
3646                    "tasks".into(),
3647                    "tasks".into(),
3648                    vec![("project_id".into(), vec!["p1".into()])],
3649                    None,
3650                )
3651                .unwrap();
3652            let commit = client
3653                .mutate(vec![Mutation::Upsert {
3654                    table: "tasks".into(),
3655                    values: Map::from_iter([
3656                        ("id".into(), json!("local")),
3657                        ("project_id".into(), json!("p1")),
3658                    ]),
3659                    base_version: None,
3660                }])
3661                .unwrap();
3662            let prepared = client.prepare_sync_round(false).unwrap();
3663            client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
3664            let payload = encode_row_json(
3665                &client.schema.tables[0],
3666                "server",
3667                &Map::from_iter([
3668                    ("id".into(), json!("server")),
3669                    ("project_id".into(), json!("p1")),
3670                ]),
3671                &client.encryption,
3672            )
3673            .unwrap();
3674            let completed = crate::CompletedSyncRound {
3675                prepared,
3676                exchange: crate::round::ExchangeResult::Reply {
3677                    transport_failed: false,
3678                    downloads: Default::default(),
3679                    response: Ok(Message {
3680                        wire_version: WIRE_VERSION,
3681                        msg_kind: MsgKind::Response,
3682                        frames: vec![
3683                            Frame::RespHeader {
3684                                required_schema_version: None,
3685                                latest_schema_version: None,
3686                                log_epoch: Some("epoch".into()),
3687                                reset_required: Some(false),
3688                            },
3689                            Frame::PushResult {
3690                                client_commit_id: commit.clone(),
3691                                status: PushStatus::Applied,
3692                                commit_seq: Some(1),
3693                                results: vec![OpResult::Applied { op_index: 0 }],
3694                            },
3695                            Frame::SubStart {
3696                                id: "tasks".into(),
3697                                status: SubStatus::Active,
3698                                reason_code: String::new(),
3699                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
3700                                bootstrap: true,
3701                            },
3702                            Frame::Commit {
3703                                commit_seq: 1,
3704                                created_at_ms: 0,
3705                                actor_id: "actor".into(),
3706                                tables: vec!["tasks".into()],
3707                                changes: vec![ssp2::model::Change {
3708                                    table_index: 0,
3709                                    row_id: "server".into(),
3710                                    op: Op::Upsert,
3711                                    row_version: Some(1),
3712                                    scopes: vec![("project_id".into(), "p1".into())],
3713                                    row: Some(payload),
3714                                }],
3715                            },
3716                            Frame::SubEnd {
3717                                next_cursor: 1,
3718                                bootstrap_state: None,
3719                            },
3720                        ],
3721                    }),
3722                },
3723            };
3724            match barrier {
3725                "preflight" => client.begin_security_preflight(),
3726                "purge" => {
3727                    client
3728                        .purge_local_data(&LocalDataPurgeInput {
3729                            purge_id: "pending-round".into(),
3730                            targets: vec![LocalDataPurgeTarget {
3731                                table: "tasks".into(),
3732                                selectors: BTreeMap::from([(
3733                                    "project_id".into(),
3734                                    vec!["p1".into()],
3735                                )]),
3736                            }],
3737                        })
3738                        .unwrap();
3739                }
3740                "rebootstrap" => {
3741                    client
3742                        .rebootstrap_local_data(&LocalDataRebootstrapInput {
3743                            rebootstrap_id: "pending-round".into(),
3744                        })
3745                        .unwrap();
3746                }
3747                "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
3748                    let trigger = if barrier == "failed-purge-replay" {
3749                        "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
3750                    } else {
3751                        "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
3752                    };
3753                    client.conn.execute_batch(trigger).unwrap();
3754                    let revision = client.local_revision();
3755                    if barrier == "failed-rebootstrap" {
3756                        assert!(client
3757                            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3758                                rebootstrap_id: "failed-round".into(),
3759                            })
3760                            .is_err());
3761                    } else {
3762                        assert!(client
3763                            .purge_local_data(&LocalDataPurgeInput {
3764                                purge_id: "failed-round".into(),
3765                                targets: vec![LocalDataPurgeTarget {
3766                                    table: "tasks".into(),
3767                                    selectors: BTreeMap::from([(
3768                                        "project_id".into(),
3769                                        vec!["p1".into()]
3770                                    )]),
3771                                }],
3772                            })
3773                            .is_err());
3774                    }
3775                    assert_eq!(client.local_revision(), revision);
3776                    assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
3777                    client
3778                        .conn
3779                        .execute_batch("DROP TRIGGER fail_local")
3780                        .unwrap();
3781                    let applied = client.apply_sync_round(completed);
3782                    assert!(
3783                        matches!(
3784                            applied,
3785                            crate::AppliedSyncRound::Complete {
3786                                outcome: SyncOutcome::Ok(_),
3787                                ..
3788                            }
3789                        ),
3790                        "{barrier}"
3791                    );
3792                    assert!(client.pending_commit_ids().is_empty());
3793                    assert_eq!(
3794                        client
3795                            .query("SELECT id FROM tasks WHERE id = 'server'", &[])
3796                            .unwrap()
3797                            .len(),
3798                        1
3799                    );
3800                    continue;
3801                }
3802                "subscription" => {
3803                    client.unsubscribe("tasks");
3804                    client
3805                        .subscribe(
3806                            "tasks".into(),
3807                            "tasks".into(),
3808                            vec![("project_id".into(), vec!["p2".into()])],
3809                            None,
3810                        )
3811                        .unwrap();
3812                }
3813                _ => unreachable!(),
3814            }
3815            let pending_before_reply = client.pending_commit_ids();
3816            let revision = client.local_revision();
3817            let applied = client.apply_sync_round(completed);
3818            assert!(
3819                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
3820            );
3821            assert_eq!(client.local_revision(), revision);
3822            assert!(client
3823                .conn
3824                .query_row(
3825                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
3826                    [],
3827                    |r| r.get::<_, bool>(0)
3828                )
3829                .unwrap());
3830            assert_eq!(client.pending_commit_ids(), pending_before_reply);
3831        }
3832    }
3833
3834    #[test]
3835    fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
3836        struct DeadlineRecording {
3837            budget: std::time::Duration,
3838            seen: Vec<Option<std::time::Instant>>,
3839        }
3840        impl Transport for DeadlineRecording {
3841            fn round_deadline(&self) -> Option<std::time::Duration> {
3842                Some(self.budget)
3843            }
3844            fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
3845                self.seen.push(deadline);
3846            }
3847            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
3848                Err(TransportError::new("transport.failed", "injected"))
3849            }
3850            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
3851                self.sync(request)
3852            }
3853            fn download_segment(
3854                &mut self,
3855                _: &SegmentRequest,
3856                _: &mut dyn FnMut(u64),
3857            ) -> Result<Vec<u8>, TransportError> {
3858                Err(TransportError::new("transport.failed", "injected"))
3859            }
3860            fn blob_upload_grant(
3861                &mut self,
3862                _: &str,
3863                _: u64,
3864                _: Option<&str>,
3865            ) -> Result<crate::BlobUploadGrant, TransportError> {
3866                Ok(crate::BlobUploadGrant::Present)
3867            }
3868            fn realtime_connect(&mut self) -> Result<(), TransportError> {
3869                Ok(())
3870            }
3871            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
3872                Ok(())
3873            }
3874            fn realtime_close(&mut self) -> Result<(), TransportError> {
3875                Ok(())
3876            }
3877        }
3878
3879        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
3880            {"name":"id","type":"string","nullable":false},
3881            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
3882        let mut client =
3883            SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
3884        client.set_meta(LOG_EPOCH_KEY, "epoch");
3885        client
3886            .upload_blob(b"pending upload", Some("text/plain".into()), None)
3887            .unwrap();
3888
3889        let mut transport = DeadlineRecording {
3890            budget: std::time::Duration::from_secs(30),
3891            seen: Vec::new(),
3892        };
3893        let prepared = client.prepare_sync_round(false).unwrap();
3894        assert_eq!(prepared.uploads.len(), 1, "one staged upload");
3895        // Upload exchange -> Continue (the pin removal is persisted by apply).
3896        let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
3897            crate::AppliedSyncRound::Continue(prepared) => prepared,
3898            crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
3899        };
3900        // Main request exchange -> Complete.
3901        let completed = prepared.exchange(&mut transport);
3902        let anchor = completed
3903            .prepared
3904            .round_deadline_at
3905            .expect("the upload round anchors a deadline");
3906        assert!(matches!(
3907            client.apply_sync_round(completed),
3908            crate::AppliedSyncRound::Complete { .. }
3909        ));
3910        assert_eq!(transport.seen.len(), 4, "one scope per exchange");
3911        assert_eq!(
3912            transport.seen[0],
3913            Some(anchor),
3914            "the upload anchors the round"
3915        );
3916        assert_eq!(
3917            transport.seen[1], None,
3918            "the upload exchange clears its scope"
3919        );
3920        assert_eq!(
3921            transport.seen[2],
3922            Some(anchor),
3923            "a continuation reuses the carried anchor instead of refreshing it"
3924        );
3925        assert_eq!(transport.seen[3], None, "the continuation clears its scope");
3926
3927        // A fresh round anchors a new deadline from the transport's (now larger)
3928        // budget and never inherits the old one; the clock granule is irrelevant.
3929        transport.budget = std::time::Duration::from_secs(60);
3930        let prepared = client.prepare_sync_round(false).unwrap();
3931        let completed = prepared.exchange(&mut transport);
3932        let fresh = completed
3933            .prepared
3934            .round_deadline_at
3935            .expect("the next round anchors a deadline");
3936        client.apply_sync_round(completed);
3937        assert!(
3938            fresh >= anchor + std::time::Duration::from_secs(30),
3939            "the next round anchors a fresh deadline from the new budget"
3940        );
3941        assert_eq!(transport.seen[4], Some(fresh));
3942        assert_eq!(
3943            transport.seen[5], None,
3944            "the new round clears its scope too"
3945        );
3946    }
3947
3948    /// Records the round-deadline traffic so a test can prove whether a round
3949    /// asked for a budget and whether it ever scoped an anchor.
3950    #[derive(Default)]
3951    struct RoundDeadlineProbe {
3952        budget: Option<std::time::Duration>,
3953        budget_queries: std::cell::Cell<usize>,
3954        scoped: Vec<Option<std::time::Instant>>,
3955    }
3956
3957    impl Transport for RoundDeadlineProbe {
3958        fn round_deadline(&self) -> Option<std::time::Duration> {
3959            self.budget_queries.set(self.budget_queries.get() + 1);
3960            self.budget
3961        }
3962        fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
3963            self.scoped.push(deadline);
3964        }
3965        fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
3966            Err(TransportError::new("transport.failed", "injected"))
3967        }
3968        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
3969            self.sync(request)
3970        }
3971        fn download_segment(
3972            &mut self,
3973            _: &SegmentRequest,
3974            _: &mut dyn FnMut(u64),
3975        ) -> Result<Vec<u8>, TransportError> {
3976            Err(TransportError::new("transport.failed", "injected"))
3977        }
3978        fn blob_upload_grant(
3979            &mut self,
3980            _: &str,
3981            _: u64,
3982            _: Option<&str>,
3983        ) -> Result<crate::BlobUploadGrant, TransportError> {
3984            Ok(crate::BlobUploadGrant::Present)
3985        }
3986        fn realtime_connect(&mut self) -> Result<(), TransportError> {
3987            Ok(())
3988        }
3989        fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
3990            Ok(())
3991        }
3992        fn realtime_close(&mut self) -> Result<(), TransportError> {
3993            Ok(())
3994        }
3995    }
3996
3997    #[test]
3998    fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
3999        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4000            {"name":"id","type":"string","nullable":false},
4001            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4002        let mut client =
4003            SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
4004        client.set_meta(LOG_EPOCH_KEY, "epoch");
4005        client
4006            .upload_blob(b"pending upload", Some("text/plain".into()), None)
4007            .unwrap();
4008        client
4009            .conn
4010            .execute_batch("DELETE FROM _syncular_blobs")
4011            .unwrap();
4012        let prepared = client.prepare_sync_round(false).unwrap();
4013        assert!(
4014            prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
4015            "the staged body must fail locally"
4016        );
4017        let mut transport = RoundDeadlineProbe {
4018            budget: Some(std::time::Duration::from_secs(30)),
4019            ..Default::default()
4020        };
4021        let completed = prepared.exchange(&mut transport);
4022        assert!(matches!(
4023            completed.exchange,
4024            crate::round::ExchangeResult::Upload { result: Err(_), .. }
4025        ));
4026        assert_eq!(
4027            transport.budget_queries.get(),
4028            0,
4029            "a round with no network work never asks for a budget"
4030        );
4031        assert_eq!(transport.scoped, vec![None]);
4032    }
4033
4034    #[test]
4035    fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
4036        struct HugeBudget;
4037        impl Transport for HugeBudget {
4038            fn round_deadline(&self) -> Option<std::time::Duration> {
4039                Some(std::time::Duration::MAX)
4040            }
4041            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4042                panic!("no network for an invalid budget")
4043            }
4044            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4045                self.sync(request)
4046            }
4047            fn download_segment(
4048                &mut self,
4049                _: &SegmentRequest,
4050                _: &mut dyn FnMut(u64),
4051            ) -> Result<Vec<u8>, TransportError> {
4052                panic!("no network for an invalid budget")
4053            }
4054            fn realtime_connect(&mut self) -> Result<(), TransportError> {
4055                Ok(())
4056            }
4057            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4058                Ok(())
4059            }
4060            fn realtime_close(&mut self) -> Result<(), TransportError> {
4061                Ok(())
4062            }
4063        }
4064
4065        let mut client = client();
4066        client.set_meta(LOG_EPOCH_KEY, "epoch");
4067        let prepared = client.prepare_sync_round(false).unwrap();
4068        let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
4069            crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
4070            crate::AppliedSyncRound::Continue(_) => {
4071                panic!("an unrepresentable budget must not continue")
4072            }
4073        };
4074        match outcome {
4075            SyncOutcome::Failed {
4076                error_code,
4077                message,
4078                ..
4079            } => {
4080                assert_eq!(error_code, "sync.invalid_request");
4081                assert_eq!(message, "transport round deadline is out of range");
4082            }
4083            _ => panic!("an unrepresentable budget must fail synchronously"),
4084        }
4085    }
4086
4087    #[test]
4088    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
4089        for failure in ["visible", "revision", "commit"] {
4090            let mut client = client();
4091            let id = client
4092                .mutate(vec![Mutation::Upsert {
4093                    table: "tasks".into(),
4094                    values: Map::from_iter([
4095                        ("id".into(), Value::from("t1")),
4096                        ("project_id".into(), Value::from("pending")),
4097                    ]),
4098                    base_version: None,
4099                }])
4100                .unwrap();
4101            client.drain_change_batches();
4102            let revision = client.local_revision();
4103            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
4104            let trigger = match failure {
4105                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
4106                "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"),
4107                _ => "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(),
4108            };
4109            client.conn.execute_batch(&trigger).unwrap();
4110            let change = ssp2::model::Change {
4111                table_index: 0,
4112                row_id: "t1".into(),
4113                op: Op::Upsert,
4114                row_version: Some(2),
4115                scopes: vec![],
4116                row: Some(
4117                    encode_row_json(
4118                        client.schema.table("tasks").unwrap(),
4119                        "t1",
4120                        &Map::from_iter([
4121                            ("id".into(), Value::from("t1")),
4122                            ("project_id".into(), Value::from("remote")),
4123                        ]),
4124                        &client.encryption,
4125                    )
4126                    .unwrap(),
4127                ),
4128            };
4129            assert!(client
4130                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
4131                .is_err());
4132            assert!(client.conn.is_autocommit());
4133            assert!(!client.overlay_dirty.get());
4134            assert_eq!(client.local_revision(), revision);
4135            assert!(client.drain_change_batches().is_empty());
4136            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4137            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
4138            assert!(client
4139                .query("SELECT * FROM _syncular_base_tasks", &[])
4140                .unwrap()
4141                .is_empty());
4142            client
4143                .conn
4144                .execute_batch("DROP TRIGGER fail_pending")
4145                .unwrap();
4146            client
4147                .apply_commit_frame(&["tasks".into()], &[change], None)
4148                .unwrap();
4149            assert_eq!(client.local_revision(), revision + 1);
4150            assert_eq!(client.pending_commit_ids(), vec![id]);
4151            // The overlay keeps the pending write visible; the row now carries
4152            // the server row's version it is layered on (§7.1).
4153            let mut expected = before[0].clone();
4154            expected.insert("_syncular_version".to_owned(), Value::from(2));
4155            assert_eq!(
4156                client.query("SELECT * FROM tasks", &[]).unwrap(),
4157                vec![expected]
4158            );
4159            assert_eq!(
4160                client
4161                    .conn
4162                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
4163                        .get::<_, String>(0))
4164                    .unwrap(),
4165                "remote"
4166            );
4167        }
4168    }
4169
4170    #[test]
4171    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
4172        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
4173            "columns": [
4174                {"name":"id", "type":"string", "nullable":false},
4175                {"name":"project_id", "type":"string", "nullable":false},
4176                {"name":"title", "type":"string", "nullable":false}],
4177            "scopes": [{"pattern":"project:{project_id}"}],
4178            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4179            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
4180        let mut incremental =
4181            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4182        let mut reference =
4183            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4184        for instance in [&mut incremental, &mut reference] {
4185            for index in 0..16 {
4186                instance
4187                    .write_base_row(
4188                        "tasks",
4189                        &vec![
4190                            Some(ColumnValue::String(format!("task{index}"))),
4191                            Some(ColumnValue::String("p0".to_owned())),
4192                            Some(ColumnValue::String(format!("base{index}"))),
4193                        ],
4194                        1,
4195                    )
4196                    .unwrap();
4197            }
4198            instance
4199                .rebuild_overlay_if_dirty()
4200                .expect("rebuild overlay");
4201            instance.overlay_rebuild_count.set(0);
4202        }
4203        for index in 0..100 {
4204            let mutations = (0..1 + index % 4)
4205                .map(|offset| {
4206                    let id = format!("task{}", (index + offset) % 24);
4207                    if (index + offset) % 7 == 0 {
4208                        Mutation::Delete {
4209                            table: "tasks".to_owned(),
4210                            row_id: id,
4211                            base_version: None,
4212                        }
4213                    } else {
4214                        Mutation::Upsert {
4215                            table: "tasks".to_owned(),
4216                            values: Map::from_iter([
4217                                ("id".to_owned(), Value::from(id)),
4218                                (
4219                                    "project_id".to_owned(),
4220                                    Value::from(format!("p{}", index % 2)),
4221                                ),
4222                                (
4223                                    "title".to_owned(),
4224                                    Value::from(format!("needle{}", (index + offset) % 24)),
4225                                ),
4226                            ]),
4227                            base_version: None,
4228                        }
4229                    }
4230                })
4231                .collect::<Vec<_>>();
4232            reference.overlay_dirty.set(true);
4233            incremental.mutate(mutations.clone()).unwrap();
4234            reference.mutate(mutations).unwrap();
4235            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4236                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
4237                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
4238            }
4239            assert_eq!(incremental.local_revision(), reference.local_revision());
4240            assert_eq!(
4241                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4242                serde_json::to_value(reference.drain_change_batches()).unwrap()
4243            );
4244        }
4245        assert_eq!(incremental.outbox.len(), 100);
4246        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4247        assert_eq!(reference.overlay_rebuild_count.get(), 100);
4248    }
4249
4250    #[test]
4251    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
4252        for fail_at in ["outbox", "revision"] {
4253            let mut client = client();
4254            let values = Map::from_iter([
4255                ("id".to_owned(), Value::from("t1")),
4256                ("project_id".to_owned(), Value::from("p1")),
4257            ]);
4258            let mutation = Mutation::Upsert {
4259                table: "tasks".to_owned(),
4260                values,
4261                base_version: None,
4262            };
4263            let trigger = if fail_at == "outbox" {
4264                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
4265            } else {
4266                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
4267            };
4268            client.conn.execute_batch(&trigger).unwrap();
4269            let revision = client.local_revision();
4270            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
4271            assert_eq!(client.local_revision(), revision);
4272            assert!(client.outbox.is_empty());
4273            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
4274            assert_eq!(
4275                client
4276                    .conn
4277                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4278                        .get::<_, i64>(0))
4279                    .unwrap(),
4280                0
4281            );
4282            assert!(client.drain_change_batches().is_empty());
4283            assert!(!client.overlay_dirty.get());
4284            client
4285                .conn
4286                .execute_batch("DROP TRIGGER fail_append")
4287                .unwrap();
4288            client.mutate(vec![mutation]).unwrap();
4289            assert_eq!(client.outbox.len(), 1);
4290            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
4291        }
4292    }
4293
4294    #[test]
4295    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
4296        let mut client = client();
4297        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
4298        let values = Map::from_iter([
4299            ("id".to_owned(), Value::from("t1")),
4300            ("project_id".to_owned(), Value::from("p1")),
4301        ]);
4302        let payload = encode_row_json(
4303            client.schema.table("tasks").unwrap(),
4304            "t1",
4305            &values,
4306            &client.encryption,
4307        )
4308        .unwrap();
4309        let first = ssp2::model::Change {
4310            table_index: 0,
4311            row_id: "t1".to_owned(),
4312            op: Op::Upsert,
4313            row_version: Some(1),
4314            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
4315            row: Some(payload),
4316        };
4317        let mut invalid = first.clone();
4318        invalid.table_index = 1;
4319        let revision = client.local_revision();
4320        client.begin_observation("failed_mirror").unwrap();
4321        assert!(client
4322            .apply_commit_changes(&tables, &[first, invalid])
4323            .is_err());
4324        client.rollback_observation("failed_mirror");
4325        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4326        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
4327        assert_eq!(
4328            client
4329                .conn
4330                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
4331                    .get::<_, i64>(
4332                    0
4333                ))
4334                .unwrap(),
4335            0
4336        );
4337        assert_eq!(client.local_revision(), revision);
4338        assert!(client.drain_change_batches().is_empty());
4339    }
4340
4341    #[test]
4342    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
4343        for failure in ["journal", "revision", "commit", "later-frame"] {
4344            let mut client = client();
4345            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4346            for id in ["first", "second"] {
4347                client
4348                    .mutate(vec![Mutation::Upsert {
4349                        table: "tasks".to_owned(),
4350                        values: Map::from_iter([
4351                            ("id".to_owned(), Value::from(id)),
4352                            ("project_id".to_owned(), Value::from("p1")),
4353                        ]),
4354                        base_version: None,
4355                    }])
4356                    .unwrap();
4357            }
4358            let (_, meta) = client.build_request(false).unwrap();
4359            // A malicious or stale response must not drain an unsent local commit.
4360            client
4361                .mutate(vec![Mutation::Upsert {
4362                    table: "tasks".to_owned(),
4363                    values: Map::from_iter([
4364                        ("id".to_owned(), Value::from("later")),
4365                        ("project_id".to_owned(), Value::from("p1")),
4366                    ]),
4367                    base_version: None,
4368                }])
4369                .unwrap();
4370            let ids = client.pending_commit_ids();
4371            let rows = client
4372                .query("SELECT * FROM tasks ORDER BY id", &[])
4373                .unwrap();
4374            let revision = client.local_revision();
4375            client.drain_change_batches();
4376            if failure != "later-frame" {
4377                client.conn.execute_batch(match failure {
4378                    "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",
4379                    "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",
4380                    _ => "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",
4381                }).unwrap();
4382            }
4383            let mut response = Message {
4384                wire_version: WIRE_VERSION,
4385                msg_kind: MsgKind::Response,
4386                frames: vec![Frame::RespHeader {
4387                    required_schema_version: None,
4388                    latest_schema_version: None,
4389                    log_epoch: Some("epoch-1".to_owned()),
4390                    reset_required: Some(false),
4391                }],
4392            };
4393            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
4394                response.frames.push(Frame::PushResult {
4395                    client_commit_id: id.to_owned(),
4396                    status: PushStatus::Applied,
4397                    commit_seq: Some(1),
4398                    results: vec![OpResult::Applied { op_index: 0 }],
4399                });
4400            }
4401            if failure == "later-frame" {
4402                response.frames.push(Frame::Error {
4403                    code: "sync.invalid_request".to_owned(),
4404                    message: "later error".to_owned(),
4405                    category: "protocol".to_owned(),
4406                    retryable: false,
4407                    recommended_action: "retry".to_owned(),
4408                    details: None,
4409                });
4410            }
4411            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4412            let outcome = client.process_response(&mut transport, response.clone(), &meta);
4413            assert!(
4414                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
4415            );
4416            if failure == "later-frame" {
4417                assert_eq!(client.pending_commit_ids(), ids[2..]);
4418                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
4419                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
4420                let batches = client.drain_change_batches();
4421                assert_eq!(batches.len(), 1);
4422                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
4423                assert_eq!(client.local_revision(), revision + 1);
4424                response.frames.pop();
4425            } else {
4426                assert_eq!(client.pending_commit_ids(), ids);
4427                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
4428                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
4429                assert_eq!(client.local_revision(), revision);
4430                assert!(client.drain_change_batches().is_empty());
4431                assert_eq!(
4432                    client
4433                        .query("SELECT * FROM tasks ORDER BY id", &[])
4434                        .unwrap(),
4435                    rows
4436                );
4437                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
4438            }
4439            for frame in &mut response.frames {
4440                if let Frame::PushResult { status, .. } = frame {
4441                    *status = PushStatus::Cached;
4442                }
4443            }
4444            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
4445            else {
4446                panic!("retry failed");
4447            };
4448            assert_eq!(
4449                report.applied,
4450                if failure == "later-frame" {
4451                    vec![]
4452                } else {
4453                    ids[..2].to_vec()
4454                }
4455            );
4456            assert_eq!(client.pending_commit_ids(), ids[2..]);
4457            let batches = client.drain_change_batches();
4458            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
4459            if let Some(batch) = batches.first() {
4460                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
4461            }
4462            assert_eq!(client.local_revision(), revision + 1);
4463        }
4464    }
4465
4466    #[test]
4467    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
4468        for failure in ["revision", "journal", "commit"] {
4469            for status in ["applied", "cached", "rejected", "conflict"] {
4470                let mut client = client();
4471                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4472                for id in ["first", "later"] {
4473                    client
4474                        .mutate(vec![Mutation::Upsert {
4475                            table: "tasks".to_owned(),
4476                            values: Map::from_iter([
4477                                ("id".to_owned(), Value::from(id)),
4478                                ("project_id".to_owned(), Value::from("p1")),
4479                            ]),
4480                            base_version: None,
4481                        }])
4482                        .unwrap();
4483                }
4484                let (_, meta) = client.build_request(false).unwrap();
4485                let ids = client.pending_commit_ids();
4486                let rows = client
4487                    .query("SELECT * FROM tasks ORDER BY id", &[])
4488                    .unwrap();
4489                let revision = client.local_revision();
4490                client.drain_change_batches();
4491                let (push_status, results) = match status {
4492                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
4493                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
4494                    "conflict" => (
4495                        PushStatus::Rejected,
4496                        vec![OpResult::Conflict {
4497                            op_index: 0,
4498                            code: "sync.version_conflict".to_owned(),
4499                            message: "conflict".to_owned(),
4500                            server_version: 1,
4501                            server_row: encode_row_json(
4502                                client.schema.table("tasks").unwrap(),
4503                                "first",
4504                                &Map::from_iter([
4505                                    ("id".to_owned(), Value::from("first")),
4506                                    ("project_id".to_owned(), Value::from("p1")),
4507                                ]),
4508                                &client.encryption,
4509                            )
4510                            .unwrap(),
4511                            conflict_columns: Vec::new(),
4512                        }],
4513                    ),
4514                    _ => (
4515                        PushStatus::Rejected,
4516                        vec![OpResult::Error {
4517                            op_index: 0,
4518                            code: "sync.validation_failed".to_owned(),
4519                            message: "rejected".to_owned(),
4520                            retryable: false,
4521                        }],
4522                    ),
4523                };
4524                let response = Message {
4525                    wire_version: WIRE_VERSION,
4526                    msg_kind: MsgKind::Response,
4527                    frames: vec![
4528                        Frame::RespHeader {
4529                            required_schema_version: None,
4530                            latest_schema_version: None,
4531                            log_epoch: Some("epoch-1".to_owned()),
4532                            reset_required: Some(false),
4533                        },
4534                        Frame::PushResult {
4535                            client_commit_id: ids[0].clone(),
4536                            status: push_status,
4537                            commit_seq: Some(1),
4538                            results,
4539                        },
4540                    ],
4541                };
4542                client.conn.execute_batch(match failure {
4543                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
4544                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
4545                    _ => "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",
4546                }).unwrap();
4547                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4548                let outcome = client.process_response(&mut transport, response.clone(), &meta);
4549                assert_eq!(
4550                    client.pending_commit_ids(),
4551                    ids,
4552                    "{failure}/{status}: pending identity"
4553                );
4554                assert_eq!(
4555                    client.local_revision(),
4556                    revision,
4557                    "{failure}/{status}: revision"
4558                );
4559                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
4560                assert!(client.conflicts().is_empty());
4561                assert!(client.rejections().is_empty());
4562                assert!(client.drain_change_batches().is_empty());
4563                assert_eq!(
4564                    client
4565                        .query("SELECT * FROM tasks ORDER BY id", &[])
4566                        .unwrap(),
4567                    rows
4568                );
4569                assert!(
4570                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
4571                    "{failure}/{status}: failed round"
4572                );
4573                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
4574                assert!(matches!(
4575                    client.process_response(&mut transport, response, &meta),
4576                    SyncOutcome::Ok(_)
4577                ));
4578                assert_eq!(client.pending_commit_ids(), ids[1..]);
4579                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
4580                assert_eq!(client.drain_change_batches().len(), 1);
4581            }
4582        }
4583    }
4584
4585    #[test]
4586    fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
4587        let mut client = client();
4588        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4589        for index in 0..4 {
4590            client
4591                .mutate(vec![Mutation::Upsert {
4592                    table: "tasks".to_owned(),
4593                    values: Map::from_iter([
4594                        ("id".to_owned(), Value::from(format!("task-{index}"))),
4595                        ("project_id".to_owned(), Value::from("project-1")),
4596                    ]),
4597                    base_version: None,
4598                }])
4599                .expect("queue commit");
4600        }
4601
4602        let (_, request_meta) = client.build_request(false).unwrap();
4603        let ids = &request_meta.pushed_ids;
4604        assert_eq!(ids.len(), 4);
4605        let response = Message {
4606            wire_version: WIRE_VERSION,
4607            msg_kind: MsgKind::Response,
4608            frames: vec![
4609                Frame::RespHeader {
4610                    required_schema_version: None,
4611                    latest_schema_version: None,
4612                    log_epoch: Some("epoch-1".to_owned()),
4613                    reset_required: Some(false),
4614                },
4615                Frame::PushResult {
4616                    client_commit_id: ids[0].clone(),
4617                    status: PushStatus::Applied,
4618                    commit_seq: Some(1),
4619                    results: vec![OpResult::Applied { op_index: 0 }],
4620                },
4621                Frame::PushResult {
4622                    client_commit_id: ids[1].clone(),
4623                    status: PushStatus::Cached,
4624                    commit_seq: Some(2),
4625                    results: vec![OpResult::Applied { op_index: 0 }],
4626                },
4627                Frame::PushResult {
4628                    client_commit_id: ids[2].clone(),
4629                    status: PushStatus::Rejected,
4630                    commit_seq: None,
4631                    results: vec![OpResult::Error {
4632                        op_index: 0,
4633                        code: "sync.validation_failed".to_owned(),
4634                        message: "rejected".to_owned(),
4635                        retryable: false,
4636                    }],
4637                },
4638                Frame::PushResult {
4639                    client_commit_id: ids[3].clone(),
4640                    status: PushStatus::Rejected,
4641                    commit_seq: None,
4642                    results: vec![OpResult::Error {
4643                        op_index: 0,
4644                        code: "sync.idempotency_cache_miss".to_owned(),
4645                        message: "retry".to_owned(),
4646                        retryable: true,
4647                    }],
4648                },
4649            ],
4650        };
4651        let mut transport =
4652            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
4653
4654        client.overlay_rebuild_count.set(0);
4655        client.outcome_prune_count.set(0);
4656        let outcome = client.process_response(&mut transport, response, &request_meta);
4657        let SyncOutcome::Ok(report) = outcome else {
4658            panic!("mixed push-result response failed");
4659        };
4660
4661        assert_eq!(report.applied, ids[..2]);
4662        assert_eq!(report.rejected, ids[2..3]);
4663        assert_eq!(report.retryable, ids[3..4]);
4664        assert_eq!(client.pending_commit_ids(), ids[3..4]);
4665        assert_eq!(
4666            client
4667                .query("SELECT id FROM tasks ORDER BY id", &[])
4668                .expect("query visible overlay"),
4669            ["task-0", "task-1", "task-3"]
4670                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
4671                .to_vec()
4672        );
4673        // The successful ACK run and final rejection have distinct durable boundaries.
4674        assert_eq!(client.overlay_rebuild_count.get(), 2);
4675        assert_eq!(client.outcome_prune_count.get(), 1);
4676    }
4677
4678    fn replay_schema(fts: bool) -> Value {
4679        json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
4680            {"name":"id","type":"string","nullable":false},
4681            {"name":"project_id","type":"string","nullable":false},
4682            {"name":"title","type":"string","nullable":false},
4683            {"name":"done","type":"boolean","nullable":false}
4684        ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
4685    }
4686
4687    #[test]
4688    fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
4689        for schema_reset in [false, true] {
4690            let mut schema = replay_schema(false);
4691            let mut client =
4692                SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
4693            let id = client
4694                .mutate(vec![Mutation::Upsert {
4695                    table: "tasks".into(),
4696                    values: Map::from_iter([
4697                        ("id".into(), json!("queued")),
4698                        ("project_id".into(), json!("p1")),
4699                        ("title".into(), json!("old")),
4700                        ("done".into(), json!(false)),
4701                    ]),
4702                    base_version: None,
4703                }])
4704                .unwrap();
4705            client.set_meta(LOG_EPOCH_KEY, "old");
4706            let revision = client.local_revision();
4707            let needed = client.sync_needed;
4708            client.drain_change_batches();
4709            client.drain_sync_intents();
4710            client.conn.execute_batch("CREATE TRIGGER refuse_revision BEFORE INSERT ON _syncular_meta WHEN NEW.key='localRevision' BEGIN SELECT RAISE(ABORT,'reset completion failed'); END;").unwrap();
4711            if schema_reset {
4712                schema["version"] = json!(2);
4713                schema["tables"][0]["columns"]
4714                    .as_array_mut()
4715                    .unwrap()
4716                    .retain(|column| column["name"] != "title");
4717                client.schema = parse_schema_json(&schema).unwrap();
4718                assert!(client.run_schema_reset().is_err());
4719            } else {
4720                assert!(client.run_log_epoch_reset("new").is_err());
4721            }
4722            assert!(!client.upgrading);
4723            assert_eq!(client.sync_needed, needed);
4724            assert_eq!(client.local_revision(), revision);
4725            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4726            assert!(client.rejections.is_empty());
4727            assert!(client.commit_outcome(&id).unwrap().is_none());
4728            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
4729            assert_eq!(
4730                client
4731                    .query("SELECT title FROM tasks WHERE id='queued'", &[])
4732                    .unwrap()[0]["title"],
4733                "old"
4734            );
4735            assert_eq!(
4736                client
4737                    .conn
4738                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4739                        .get::<_, i64>(0))
4740                    .unwrap(),
4741                1
4742            );
4743            assert!(client.drain_change_batches().is_empty());
4744            assert!(client.drain_sync_intents().is_empty());
4745            assert!(client.conn.is_autocommit());
4746        }
4747    }
4748
4749    #[test]
4750    fn cached_blob_cleanup_propagates_delete_failures() {
4751        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4752            {"name":"id","type":"string","nullable":false},
4753            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4754        let mut client =
4755            SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
4756        client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
4757        client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
4758        client
4759            .subscribe("attachments".into(), "attachments".into(), vec![], None)
4760            .unwrap();
4761        client.subs[0].effective = Some(vec![]);
4762        client.persist_sub(&client.subs[0]).unwrap();
4763        let revision = client.local_revision();
4764        let (_, meta) = client.build_request(false).unwrap();
4765        let mut transport = CountingRealtimeTransport::default();
4766        let mut report = SyncReport::default();
4767        assert!(client
4768            .process_section(
4769                &mut transport,
4770                "attachments",
4771                SubStatus::Revoked,
4772                "sync.scope_revoked",
4773                vec![],
4774                vec![],
4775                Some((0, None)),
4776                &meta,
4777                &mut report
4778            )
4779            .is_err());
4780        assert_eq!(client.subs[0].state, SubState::Active);
4781        assert_eq!(client.local_revision(), revision);
4782        assert!(report.revoked.is_empty());
4783        assert!(client.conn.is_autocommit());
4784        client
4785            .conn
4786            .execute_batch("DROP TRIGGER refuse_blob_cleanup")
4787            .unwrap();
4788        assert!(client
4789            .process_section(
4790                &mut transport,
4791                "attachments",
4792                SubStatus::Revoked,
4793                "sync.scope_revoked",
4794                vec![],
4795                vec![],
4796                Some((0, None)),
4797                &meta,
4798                &mut report
4799            )
4800            .is_ok());
4801        assert_eq!(client.subs[0].state, SubState::Revoked);
4802        assert_eq!(
4803            client
4804                .conn
4805                .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
4806                    .get::<_, i64>(0))
4807                .unwrap(),
4808            0
4809        );
4810    }
4811
4812    #[test]
4813    fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
4814        let path =
4815            std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
4816        let schema = replay_schema(false);
4817        let mut client = SyncClient::open_path(
4818            "replay".into(),
4819            &schema,
4820            ClientLimits::default(),
4821            path.to_str().unwrap(),
4822        )
4823        .unwrap();
4824        let ids = ["early", "bad"].map(|id| {
4825            client
4826                .mutate(vec![Mutation::Upsert {
4827                    table: "tasks".into(),
4828                    values: Map::from_iter([
4829                        ("id".into(), json!(id)),
4830                        ("project_id".into(), json!("p1")),
4831                        ("title".into(), json!(id)),
4832                        ("done".into(), json!(false)),
4833                    ]),
4834                    base_version: None,
4835                }])
4836                .unwrap()
4837        });
4838        client.conn.execute_batch("DELETE FROM tasks; CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.id='bad' BEGIN SELECT RAISE(ABORT,'replay failed'); END;").unwrap();
4839        let revision = client.local_revision();
4840        drop(client);
4841        let error = SyncClient::open_path(
4842            "replay".into(),
4843            &schema,
4844            ClientLimits::default(),
4845            path.to_str().unwrap(),
4846        )
4847        .err()
4848        .expect("reopen must fail");
4849        assert!(error.contains("replay failed"));
4850        let conn = Connection::open(&path).unwrap();
4851        assert_eq!(
4852            conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
4853                .unwrap(),
4854            0
4855        );
4856        assert_eq!(
4857            conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4858                .get::<_, i64>(0))
4859                .unwrap(),
4860            2
4861        );
4862        assert_eq!(
4863            meta_get(&conn, LOCAL_REVISION_KEY),
4864            Some(revision.to_string())
4865        );
4866        conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
4867        drop(conn);
4868        let reopened = SyncClient::open_path(
4869            "replay".into(),
4870            &schema,
4871            ClientLimits::default(),
4872            path.to_str().unwrap(),
4873        )
4874        .unwrap();
4875        assert_eq!(reopened.pending_commit_ids(), ids);
4876        assert_eq!(
4877            reopened
4878                .query("SELECT id FROM tasks ORDER BY id", &[])
4879                .unwrap()
4880                .len(),
4881            2
4882        );
4883        drop(reopened);
4884        std::fs::remove_file(path).unwrap();
4885    }
4886
4887    #[test]
4888    fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
4889        let schema = replay_schema(false);
4890        let mut client =
4891            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
4892        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4893        client
4894            .write_base_row(
4895                "tasks",
4896                &vec![
4897                    Some(ColumnValue::String("queued".into())),
4898                    Some(ColumnValue::String("p1".into())),
4899                    Some(ColumnValue::String("server".into())),
4900                    Some(ColumnValue::Boolean(false)),
4901                ],
4902                1,
4903            )
4904            .unwrap();
4905        client.rebuild_overlay().unwrap();
4906        let id = client
4907            .patch(
4908                "tasks",
4909                "queued",
4910                Map::from_iter([("title".into(), json!("local"))]),
4911                Some(1),
4912            )
4913            .unwrap();
4914        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
4915        let base = client
4916            .query("SELECT * FROM _syncular_base_tasks", &[])
4917            .unwrap();
4918        let revision = client.local_revision();
4919        client.drain_change_batches();
4920        client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
4921        let remote = vec![
4922            Some(ColumnValue::String("queued".into())),
4923            Some(ColumnValue::String("p1".into())),
4924            Some(ColumnValue::String("remote".into())),
4925            Some(ColumnValue::Boolean(false)),
4926        ];
4927        let bytes = crate::values::encode_row_json(
4928            client.schema.table("tasks").unwrap(),
4929            "queued",
4930            &Map::from_iter(
4931                client
4932                    .schema
4933                    .table("tasks")
4934                    .unwrap()
4935                    .columns
4936                    .iter()
4937                    .zip(&remote)
4938                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
4939            ),
4940            &client.encryption,
4941        )
4942        .unwrap();
4943        let change = ssp2::model::Change {
4944            table_index: 0,
4945            op: Op::Upsert,
4946            row_id: "queued".into(),
4947            row_version: Some(2),
4948            scopes: vec![("project_id".into(), "p1".into())],
4949            row: Some(bytes),
4950        };
4951        assert!(client
4952            .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
4953            .is_err());
4954        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
4955        assert_eq!(
4956            client
4957                .query("SELECT * FROM _syncular_base_tasks", &[])
4958                .unwrap(),
4959            base
4960        );
4961        assert_eq!(client.local_revision(), revision);
4962        assert!(client.drain_change_batches().is_empty());
4963        let response = Message {
4964            wire_version: 2,
4965            msg_kind: MsgKind::Response,
4966            frames: vec![
4967                Frame::RespHeader {
4968                    required_schema_version: None,
4969                    latest_schema_version: None,
4970                    log_epoch: Some("epoch-1".into()),
4971                    reset_required: Some(false),
4972                },
4973                Frame::PushResult {
4974                    client_commit_id: id.clone(),
4975                    status: PushStatus::Applied,
4976                    commit_seq: Some(2),
4977                    results: vec![OpResult::Applied { op_index: 0 }],
4978                },
4979            ],
4980        };
4981        let (_, meta) = client.build_request(false).unwrap();
4982        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4983        assert!(
4984            matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
4985        );
4986        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4987        assert!(client.commit_outcome(&id).unwrap().is_none());
4988        assert_eq!(
4989            client
4990                .conn
4991                .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
4992                    .get::<_, i64>(
4993                    0
4994                ))
4995                .unwrap(),
4996            0
4997        );
4998        assert_eq!(
4999            client
5000                .conn
5001                .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
5002                    row.get::<_, i64>(0)
5003                })
5004                .unwrap(),
5005            0
5006        );
5007        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5008        assert_eq!(client.local_revision(), revision);
5009        assert!(client.drain_change_batches().is_empty());
5010        client
5011            .conn
5012            .execute_batch("DROP TRIGGER fail_replay")
5013            .unwrap();
5014        assert!(matches!(
5015            client.process_response(&mut transport, response, &meta),
5016            SyncOutcome::Ok(_)
5017        ));
5018        client
5019            .apply_commit_frame(&["tasks".into()], &[change], Some(2))
5020            .unwrap();
5021        assert!(client.pending_commit_ids().is_empty());
5022        assert_eq!(
5023            client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
5024            json!("remote")
5025        );
5026    }
5027
5028    #[test]
5029    fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
5030        let client = SyncClient::new(
5031            "replay".into(),
5032            &replay_schema(false),
5033            ClientLimits::default(),
5034        )
5035        .unwrap();
5036        let table = client.schema.table("tasks").unwrap();
5037        let patch = OutboxOp {
5038            table: "tasks".into(),
5039            row_id: "absent".into(),
5040            upsert: true,
5041            base_version: Some(1),
5042            values: Some(Map::from_iter([
5043                ("id".into(), json!("absent")),
5044                ("title".into(), json!("patch")),
5045            ])),
5046        };
5047        assert!(client.visible_row(table, "absent").unwrap().is_none());
5048        client
5049            .apply_outbox_ops([&patch])
5050            .expect("sparse replay over truly absent row");
5051        client
5052            .conn
5053            .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
5054            .unwrap();
5055        assert!(client
5056            .visible_row(table, "bad")
5057            .unwrap_err()
5058            .starts_with("sync.local_corrupt:"));
5059        let mut invalid = patch.clone();
5060        invalid.values = Some(Map::from_iter([
5061            ("id".into(), json!("bad-value")),
5062            ("project_id".into(), json!("p1")),
5063            ("title".into(), json!("title")),
5064            ("done".into(), json!({"bad":true})),
5065        ]));
5066        assert!(client
5067            .apply_outbox_ops([&invalid])
5068            .unwrap_err()
5069            .starts_with("sync.local_corrupt:"));
5070        client.conn.execute_batch("DROP TABLE tasks").unwrap();
5071        assert!(
5072            client.apply_outbox_ops([&patch]).is_err(),
5073            "SQL failure must not count as absence"
5074        );
5075    }
5076
5077    #[test]
5078    fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
5079        let mut schema = replay_schema(false);
5080        schema["tables"][0]["indexes"] =
5081            json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
5082        let mut client =
5083            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5084        let id = client
5085            .mutate(vec![Mutation::Upsert {
5086                table: "tasks".into(),
5087                values: Map::from_iter([
5088                    ("id".into(), json!("queued")),
5089                    ("project_id".into(), json!("p1")),
5090                    ("title".into(), json!("occupied")),
5091                    ("done".into(), json!(false)),
5092                ]),
5093                base_version: None,
5094            }])
5095            .unwrap();
5096        client
5097            .write_base_row(
5098                "tasks",
5099                &vec![
5100                    Some(ColumnValue::String("server".into())),
5101                    Some(ColumnValue::String("p1".into())),
5102                    Some(ColumnValue::String("occupied".into())),
5103                    Some(ColumnValue::Boolean(false)),
5104                ],
5105                1,
5106            )
5107            .unwrap();
5108        client.rebuild_overlay_if_dirty().unwrap();
5109        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5110        assert!(client
5111            .visible_row(client.schema.table("tasks").unwrap(), "queued")
5112            .unwrap()
5113            .is_none());
5114        assert!(client.rejections().is_empty());
5115        client.delete_base_row("tasks", "server").unwrap();
5116        client.rebuild_overlay_if_dirty().unwrap();
5117        assert!(client
5118            .visible_row(client.schema.table("tasks").unwrap(), "queued")
5119            .unwrap()
5120            .is_some());
5121        client
5122            .write_base_row(
5123                "tasks",
5124                &vec![
5125                    Some(ColumnValue::String("base".into())),
5126                    Some(ColumnValue::String("p1".into())),
5127                    Some(ColumnValue::String("base-title".into())),
5128                    Some(ColumnValue::Boolean(false)),
5129                ],
5130                1,
5131            )
5132            .unwrap();
5133        client.conn.execute_batch("DROP TABLE tasks").unwrap();
5134        client
5135            .retain_failed_commit(
5136                "retained",
5137                &[OutboxOp {
5138                    table: "tasks".into(),
5139                    row_id: "base".into(),
5140                    upsert: true,
5141                    base_version: Some(1),
5142                    values: Some(Map::from_iter([
5143                        ("id".into(), json!("base")),
5144                        ("title".into(), json!("patch")),
5145                    ])),
5146                }],
5147            )
5148            .expect("an existing base must not read the unavailable visible table");
5149    }
5150
5151    #[test]
5152    fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
5153        for failure in ["fts", "release"] {
5154            let mut client = SyncClient::new(
5155                "replay".into(),
5156                &replay_schema(true),
5157                ClientLimits::default(),
5158            )
5159            .unwrap();
5160            client
5161                .mutate(vec![Mutation::Upsert {
5162                    table: "tasks".into(),
5163                    values: Map::from_iter([
5164                        ("id".into(), json!("queued")),
5165                        ("project_id".into(), json!("p1")),
5166                        ("title".into(), json!("needle")),
5167                        ("done".into(), json!(false)),
5168                    ]),
5169                    base_version: None,
5170                }])
5171                .unwrap();
5172            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5173            let fts_before = client
5174                .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5175                .unwrap();
5176            let triggers_before: Vec<String> = client
5177                .conn
5178                .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
5179                .unwrap()
5180                .query_map([], |row| row.get(0))
5181                .unwrap()
5182                .collect::<Result<_, _>>()
5183                .unwrap();
5184            if failure == "fts" {
5185                client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
5186            } else {
5187                client.conn.execute_batch("PRAGMA foreign_keys=ON; CREATE TABLE parent(id TEXT PRIMARY KEY); CREATE TABLE deferred(id TEXT REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_release AFTER INSERT ON tasks BEGIN INSERT INTO deferred VALUES ('missing'); END;").unwrap();
5188            }
5189            client.overlay_dirty.set(true);
5190            assert!(client.rebuild_overlay().is_err());
5191            assert!(client.overlay_dirty.get());
5192            assert!(client.conn.is_autocommit());
5193            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5194            assert_eq!(
5195                client
5196                    .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5197                    .unwrap(),
5198                fts_before
5199            );
5200            let original_triggers: Vec<String> = client.conn.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name NOT LIKE 'fail_%' ORDER BY name").unwrap().query_map([], |row| row.get(0)).unwrap().collect::<Result<_,_>>().unwrap();
5201            assert_eq!(original_triggers, triggers_before);
5202            assert_eq!(client.pending_commit_ids().len(), 1);
5203            client
5204                .conn
5205                .execute_batch(if failure == "fts" {
5206                    "DROP TRIGGER fail_fts"
5207                } else {
5208                    "DROP TRIGGER fail_release"
5209                })
5210                .unwrap();
5211            client
5212                .rebuild_overlay()
5213                .expect("retry after removing the fault");
5214        }
5215    }
5216
5217    #[test]
5218    fn secondary_unique_collision_preserves_existing_synced_row() {
5219        let client = SyncClient::new(
5220            "unique-upsert-test".to_owned(),
5221            &json!({
5222                "version": 1,
5223                "tables": [{
5224                    "name": "tasks",
5225                    "primaryKey": "id",
5226                    "columns": [
5227                        { "name": "id", "type": "string", "nullable": false },
5228                        { "name": "project_id", "type": "string", "nullable": false },
5229                        { "name": "title", "type": "string", "nullable": false }
5230                    ],
5231                    "scopes": [{ "pattern": "project:{project_id}" }],
5232                    "indexes": [{
5233                        "name": "tasks_by_project_title",
5234                        "columns": ["project_id", "title"],
5235                        "unique": true
5236                    }]
5237                }]
5238            }),
5239            ClientLimits::default(),
5240        )
5241        .expect("test client");
5242        let table = client.schema.table("tasks").expect("tasks table");
5243        let sql = client.insert_row_sql(&base_table("tasks"), table);
5244
5245        client
5246            .conn
5247            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
5248            .expect("insert first row");
5249        client
5250            .conn
5251            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
5252            .expect("update same primary key");
5253        client
5254            .conn
5255            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
5256            .expect("insert second row");
5257        assert!(client
5258            .conn
5259            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
5260            .is_err());
5261
5262        let rows = client
5263            .conn
5264            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
5265            .expect("prepare rows")
5266            .query_map([], |row| {
5267                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5268            })
5269            .expect("query rows")
5270            .collect::<Result<Vec<_>, _>>()
5271            .expect("collect rows");
5272        assert_eq!(
5273            rows,
5274            vec![
5275                ("t1".to_owned(), "updated".to_owned()),
5276                ("t2".to_owned(), "original".to_owned())
5277            ]
5278        );
5279    }
5280
5281    #[test]
5282    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
5283        let path = std::env::temp_dir().join(format!(
5284            "syncular-startup-intent-{}.db",
5285            uuid::Uuid::new_v4()
5286        ));
5287        let schema = json!({
5288            "version": 1,
5289            "tables": [{
5290                "name": "tasks",
5291                "primaryKey": "id",
5292                "columns": [
5293                    { "name": "id", "type": "string", "nullable": false },
5294                    { "name": "project_id", "type": "string", "nullable": false }
5295                ],
5296                "scopes": [{ "pattern": "project:{project_id}" }]
5297            }]
5298        });
5299
5300        {
5301            let mut first = SyncClient::open_path_with_identity(
5302                None,
5303                &schema,
5304                ClientLimits::default(),
5305                path.to_str().expect("UTF-8 temp path"),
5306            )
5307            .expect("first open");
5308            first
5309                .set_window(
5310                    &WindowBase {
5311                        table: "tasks".to_owned(),
5312                        variable: "project_id".to_owned(),
5313                        fixed_scopes: Vec::new(),
5314                        params: None,
5315                    },
5316                    &["persisted".to_owned()],
5317                )
5318                .expect("persist window");
5319        }
5320
5321        let mut reopened = SyncClient::open_path_with_identity(
5322            None,
5323            &schema,
5324            ClientLimits::default(),
5325            path.to_str().expect("UTF-8 temp path"),
5326        )
5327        .expect("reopen");
5328        assert!(reopened.sync_needed());
5329        assert!(matches!(
5330            reopened.drain_sync_intents().as_slice(),
5331            [SyncIntent::Interactive]
5332        ));
5333        drop(reopened);
5334        std::fs::remove_file(path).expect("remove temp database");
5335    }
5336
5337    #[test]
5338    fn reopening_preserves_immutable_subscription_identity_and_progress() {
5339        let path = std::env::temp_dir().join(format!(
5340            "syncular-subscription-identity-{}.db",
5341            uuid::Uuid::new_v4()
5342        ));
5343        let schema = json!({
5344            "version": 1,
5345            "tables": [
5346                {
5347                    "name": "tasks",
5348                    "primaryKey": "id",
5349                    "columns": [
5350                        { "name": "id", "type": "string", "nullable": false },
5351                        { "name": "project_id", "type": "string", "nullable": false }
5352                    ],
5353                    "scopes": [{ "pattern": "project:{project_id}" }]
5354                },
5355                {
5356                    "name": "docs",
5357                    "primaryKey": "id",
5358                    "columns": [
5359                        { "name": "id", "type": "string", "nullable": false },
5360                        { "name": "org_id", "type": "string", "nullable": false },
5361                        { "name": "project_id", "type": "string", "nullable": false }
5362                    ],
5363                    "scopes": [
5364                        { "pattern": "org:{org_id}" },
5365                        { "pattern": "project:{projectId}", "column": "project_id" }
5366                    ]
5367                }
5368            ]
5369        });
5370
5371        {
5372            let mut first = SyncClient::open_path_with_identity(
5373                None,
5374                &schema,
5375                ClientLimits::default(),
5376                path.to_str().expect("UTF-8 temp path"),
5377            )
5378            .expect("first open");
5379            first
5380                .subscribe(
5381                    "stable-subscription".to_owned(),
5382                    "tasks".to_owned(),
5383                    vec![(
5384                        "project_id".to_owned(),
5385                        vec!["p2".to_owned(), "p1".to_owned()],
5386                    )],
5387                    Some(r#"{"view":"v1"}"#.to_owned()),
5388                )
5389                .expect("persist subscription");
5390            let persisted = {
5391                let subscription = first
5392                    .subs
5393                    .iter_mut()
5394                    .find(|subscription| subscription.id == "stable-subscription")
5395                    .expect("subscription");
5396                subscription.cursor = 41;
5397                subscription.bootstrap_state = Some("resume-token".to_owned());
5398                subscription.effective = Some(vec![(
5399                    "project_id".to_owned(),
5400                    vec!["p1".to_owned(), "p2".to_owned()],
5401                )]);
5402                subscription.synced_once = true;
5403                subscription.clone()
5404            };
5405            first.persist_sub(&persisted).unwrap();
5406        }
5407
5408        let mut reopened = SyncClient::open_path_with_identity(
5409            None,
5410            &schema,
5411            ClientLimits::default(),
5412            path.to_str().expect("UTF-8 temp path"),
5413        )
5414        .expect("reopen");
5415        let progress = reopened
5416            .subscription_state("stable-subscription")
5417            .expect("persisted state");
5418        assert_eq!(progress.cursor, 41);
5419        assert!(progress.has_resume_token);
5420
5421        reopened
5422            .subscribe(
5423                "stable-subscription".to_owned(),
5424                "tasks".to_owned(),
5425                vec![(
5426                    "project_id".to_owned(),
5427                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
5428                )],
5429                Some(r#"{"view":"v1"}"#.to_owned()),
5430            )
5431            .expect("canonical intent is idempotent");
5432        assert_eq!(
5433            reopened
5434                .subscription_state("stable-subscription")
5435                .expect("unchanged state")
5436                .cursor,
5437            progress.cursor
5438        );
5439
5440        for (table, scopes, params) in [
5441            (
5442                "tasks",
5443                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5444                Some(r#"{"view":"v1"}"#.to_owned()),
5445            ),
5446            (
5447                "tasks",
5448                vec![(
5449                    "project_id".to_owned(),
5450                    vec!["p2".to_owned(), "p1".to_owned()],
5451                )],
5452                Some(r#"{"view":"v2"}"#.to_owned()),
5453            ),
5454            (
5455                "docs",
5456                vec![
5457                    ("org_id".to_owned(), vec!["o1".to_owned()]),
5458                    ("projectId".to_owned(), vec!["p1".to_owned()]),
5459                ],
5460                Some(r#"{"view":"v1"}"#.to_owned()),
5461            ),
5462        ] {
5463            let error = reopened
5464                .subscribe(
5465                    "stable-subscription".to_owned(),
5466                    table.to_owned(),
5467                    scopes,
5468                    params,
5469                )
5470                .expect_err("identity rebind must fail");
5471            assert!(error.starts_with("client.subscription_intent_mismatch:"));
5472            assert_eq!(
5473                reopened
5474                    .subscription_state("stable-subscription")
5475                    .expect("unchanged state")
5476                    .cursor,
5477                progress.cursor
5478            );
5479        }
5480
5481        drop(reopened);
5482        std::fs::remove_file(path).expect("remove temp database");
5483    }
5484
5485    #[test]
5486    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
5487        let path = std::env::temp_dir().join(format!(
5488            "syncular-satisfied-schema-floor-{}.db",
5489            uuid::Uuid::new_v4()
5490        ));
5491        let schema = json!({
5492            "version": 23,
5493            "tables": [{
5494                "name": "tasks",
5495                "primaryKey": "id",
5496                "columns": [
5497                    { "name": "id", "type": "string", "nullable": false },
5498                    { "name": "project_id", "type": "string", "nullable": false }
5499                ],
5500                "scopes": [{ "pattern": "project:{project_id}" }]
5501            }]
5502        });
5503
5504        {
5505            let mut first = SyncClient::open_path_with_identity(
5506                None,
5507                &schema,
5508                ClientLimits::default(),
5509                path.to_str().expect("UTF-8 temp path"),
5510            )
5511            .expect("first open");
5512            first
5513                .subscribe(
5514                    "tasks".to_owned(),
5515                    "tasks".to_owned(),
5516                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5517                    None,
5518                )
5519                .expect("persist subscription");
5520            first.set_schema_floor(Some(SchemaFloor {
5521                required_schema_version: Some(22),
5522                latest_schema_version: Some(22),
5523            }));
5524        }
5525
5526        let mut reopened = SyncClient::open_path_with_identity(
5527            None,
5528            &schema,
5529            ClientLimits::default(),
5530            path.to_str().expect("UTF-8 temp path"),
5531        )
5532        .expect("reopen");
5533        assert!(reopened.schema_floor().is_none());
5534        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
5535        assert!(reopened.sync_needed());
5536        assert!(matches!(
5537            reopened.drain_sync_intents().as_slice(),
5538            [SyncIntent::Interactive]
5539        ));
5540        drop(reopened);
5541        std::fs::remove_file(path).expect("remove temp database");
5542    }
5543
5544    #[test]
5545    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
5546        let path = std::env::temp_dir().join(format!(
5547            "syncular-unsatisfied-schema-floor-{}.db",
5548            uuid::Uuid::new_v4()
5549        ));
5550        let schema = json!({
5551            "version": 1,
5552            "tables": [{
5553                "name": "tasks",
5554                "primaryKey": "id",
5555                "columns": [
5556                    { "name": "id", "type": "string", "nullable": false },
5557                    { "name": "project_id", "type": "string", "nullable": false }
5558                ],
5559                "scopes": [{ "pattern": "project:{project_id}" }]
5560            }]
5561        });
5562
5563        {
5564            let mut first = SyncClient::open_path_with_identity(
5565                None,
5566                &schema,
5567                ClientLimits::default(),
5568                path.to_str().expect("UTF-8 temp path"),
5569            )
5570            .expect("first open");
5571            first
5572                .subscribe(
5573                    "tasks".to_owned(),
5574                    "tasks".to_owned(),
5575                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5576                    None,
5577                )
5578                .expect("persist subscription");
5579            first.set_schema_floor(Some(SchemaFloor {
5580                required_schema_version: Some(2),
5581                latest_schema_version: Some(2),
5582            }));
5583        }
5584
5585        let reopened = SyncClient::open_path_with_identity(
5586            None,
5587            &schema,
5588            ClientLimits::default(),
5589            path.to_str().expect("UTF-8 temp path"),
5590        )
5591        .expect("reopen");
5592        assert_eq!(
5593            reopened.schema_floor(),
5594            Some(&SchemaFloor {
5595                required_schema_version: Some(2),
5596                latest_schema_version: Some(2),
5597            })
5598        );
5599        assert!(!reopened.sync_needed());
5600        drop(reopened);
5601        std::fs::remove_file(path).expect("remove temp database");
5602    }
5603
5604    #[test]
5605    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
5606        let path = std::env::temp_dir().join(format!(
5607            "syncular-indexed-column-bump-{}.db",
5608            uuid::Uuid::new_v4()
5609        ));
5610        let old_schema = json!({
5611            "version": 1,
5612            "tables": [
5613                {
5614                    "name": "tasks",
5615                    "primaryKey": "id",
5616                    "columns": [
5617                        { "name": "id", "type": "string", "nullable": false },
5618                        { "name": "project_id", "type": "string", "nullable": false }
5619                    ],
5620                    "scopes": [{ "pattern": "project:{project_id}" }]
5621                },
5622                {
5623                    "name": "legacy",
5624                    "primaryKey": "id",
5625                    "columns": [
5626                        { "name": "id", "type": "string", "nullable": false },
5627                        { "name": "project_id", "type": "string", "nullable": false }
5628                    ],
5629                    "scopes": [{ "pattern": "project:{project_id}" }]
5630                }
5631            ]
5632        });
5633        {
5634            let mut first = SyncClient::open_path_with_identity(
5635                None,
5636                &old_schema,
5637                ClientLimits::default(),
5638                path.to_str().expect("UTF-8 temp path"),
5639            )
5640            .expect("open old schema");
5641            first
5642                .subscribe(
5643                    "legacy-sub".to_owned(),
5644                    "legacy".to_owned(),
5645                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5646                    None,
5647                )
5648                .expect("persist legacy subscription");
5649        }
5650
5651        let new_schema = json!({
5652            "version": 2,
5653            "tables": [{
5654                "name": "tasks",
5655                "primaryKey": "id",
5656                "columns": [
5657                    { "name": "id", "type": "string", "nullable": false },
5658                    { "name": "project_id", "type": "string", "nullable": false },
5659                    { "name": "facility_membership_id", "type": "string", "nullable": true }
5660                ],
5661                "scopes": [{ "pattern": "project:{project_id}" }],
5662                "indexes": [{
5663                    "name": "tasks_by_membership",
5664                    "columns": ["project_id", "facility_membership_id"],
5665                    "unique": false
5666                }]
5667            }]
5668        });
5669        let upgraded = SyncClient::open_path_with_identity(
5670            None,
5671            &new_schema,
5672            ClientLimits::default(),
5673            path.to_str().expect("UTF-8 temp path"),
5674        )
5675        .expect("open upgraded schema");
5676        let columns = upgraded
5677            .conn
5678            .prepare("PRAGMA table_info(tasks)")
5679            .expect("prepare columns")
5680            .query_map([], |row| row.get::<_, String>(1))
5681            .expect("query columns")
5682            .collect::<Result<Vec<_>, _>>()
5683            .expect("collect columns");
5684        assert!(columns.contains(&"facility_membership_id".to_owned()));
5685        let index_count: i64 = upgraded
5686            .conn
5687            .query_row(
5688                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
5689                [],
5690                |row| row.get(0),
5691            )
5692            .expect("query index");
5693        assert_eq!(index_count, 1);
5694        assert!(upgraded.subscription_state("legacy-sub").is_none());
5695        drop(upgraded);
5696        std::fs::remove_file(path).expect("remove temp database");
5697    }
5698
5699    #[test]
5700    fn migrates_pre_envelope_outcome_journal_additively() {
5701        let path = std::env::temp_dir().join(format!(
5702            "syncular-outcome-migration-{}.db",
5703            uuid::Uuid::new_v4()
5704        ));
5705        let conn = Connection::open(&path).expect("open legacy database");
5706        conn.execute_batch(
5707            "CREATE TABLE _syncular_commit_outcomes (
5708               seq INTEGER PRIMARY KEY AUTOINCREMENT,
5709               client_commit_id TEXT NOT NULL UNIQUE,
5710               status TEXT NOT NULL,
5711               recorded_at_ms INTEGER NOT NULL,
5712               results_json TEXT NOT NULL,
5713               resolution TEXT NOT NULL DEFAULT 'active',
5714               resolved_at_ms INTEGER,
5715               replacement_client_commit_id TEXT);",
5716        )
5717        .expect("create legacy outcome journal");
5718        drop(conn);
5719        let schema = json!({
5720            "version": 1,
5721            "tables": [{
5722                "name": "tasks",
5723                "primaryKey": "id",
5724                "columns": [
5725                    { "name": "id", "type": "string", "nullable": false },
5726                    { "name": "project_id", "type": "string", "nullable": false }
5727                ],
5728                "scopes": [{ "pattern": "project:{project_id}" }]
5729            }]
5730        });
5731        let client = SyncClient::open_path(
5732            "migration-native".to_owned(),
5733            &schema,
5734            ClientLimits::default(),
5735            path.to_str().expect("UTF-8 temp path"),
5736        )
5737        .expect("migrate database");
5738        let has_operations = client
5739            .conn
5740            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
5741            .expect("prepare table info")
5742            .query_map([], |row| row.get::<_, String>(1))
5743            .expect("query table info")
5744            .filter_map(Result::ok)
5745            .any(|column| column == "operations_json");
5746        assert!(has_operations);
5747        drop(client);
5748        std::fs::remove_file(path).expect("remove temp database");
5749    }
5750
5751    #[test]
5752    fn retained_rejection_survives_file_reopen_and_security_purge() {
5753        let path =
5754            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
5755        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
5756            { "name": "id", "type": "string", "nullable": false },
5757            { "name": "project_id", "type": "string", "nullable": false },
5758            { "name": "title", "type": "string", "nullable": false }
5759        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
5760        {
5761            let mut first = SyncClient::open_path(
5762                "retained-native".to_owned(),
5763                &schema,
5764                ClientLimits::default(),
5765                path.to_str().unwrap(),
5766            )
5767            .unwrap();
5768            first
5769                .write_base_row(
5770                    "tasks",
5771                    &vec![
5772                        Some(ColumnValue::String("t1".into())),
5773                        Some(ColumnValue::String("p1".into())),
5774                        Some(ColumnValue::String("server".into())),
5775                    ],
5776                    1,
5777                )
5778                .unwrap();
5779            first.rebuild_overlay().expect("rebuild overlay");
5780            let operation = OutboxOp {
5781                table: "tasks".into(),
5782                row_id: "t1".into(),
5783                upsert: true,
5784                base_version: Some(1),
5785                values: Some(Map::from_iter([
5786                    ("id".into(), json!("t1")),
5787                    ("title".into(), json!("mine")),
5788                ])),
5789            };
5790            first
5791                .retain_failed_commit("retained", std::slice::from_ref(&operation))
5792                .unwrap();
5793            first
5794                .persist_commit_outcome(
5795                    "retained",
5796                    CommitOutcomeStatus::Rejected,
5797                    &[CommitOperationOutcome::Error {
5798                        rejection: RejectionRecord {
5799                            client_commit_id: "retained".into(),
5800                            op_index: 0,
5801                            code: "validation.denied".into(),
5802                            message: "denied".into(),
5803                            retryable: false,
5804                            details: None,
5805                            operation: None,
5806                        },
5807                    }],
5808                    Some(&[operation]),
5809                )
5810                .unwrap();
5811            first.rebuild_overlay().expect("rebuild overlay");
5812        }
5813        {
5814            let mut reopened = SyncClient::open_path(
5815                "retained-native".to_owned(),
5816                &schema,
5817                ClientLimits::default(),
5818                path.to_str().unwrap(),
5819            )
5820            .unwrap();
5821            let row = reopened
5822                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
5823                .expect("read visible row")
5824                .unwrap();
5825            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
5826            assert_eq!(
5827                reopened
5828                    .commit_outcome("retained")
5829                    .unwrap()
5830                    .unwrap()
5831                    .retained_rows
5832                    .unwrap()[0]
5833                    .server_row
5834                    .as_ref()
5835                    .unwrap()["title"],
5836                json!("server")
5837            );
5838            reopened
5839                .conn
5840                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
5841                .unwrap();
5842            reopened.rebuild_overlay().expect("rebuild overlay");
5843            assert!(reopened
5844                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
5845                .expect("read visible row")
5846                .is_none());
5847            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
5848            assert!(retained.retained_rows.is_some());
5849            assert_eq!(
5850                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
5851                json!("mine")
5852            );
5853            reopened
5854                .purge_local_data(&LocalDataPurgeInput {
5855                    purge_id: "protected-purge".into(),
5856                    targets: vec![LocalDataPurgeTarget {
5857                        table: "tasks".into(),
5858                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
5859                    }],
5860                })
5861                .unwrap();
5862            assert!(reopened
5863                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
5864                .expect("read visible row")
5865                .is_none());
5866            assert!(reopened
5867                .commit_outcome("retained")
5868                .unwrap()
5869                .unwrap()
5870                .retained_rows
5871                .is_none());
5872        }
5873        std::fs::remove_file(path).unwrap();
5874    }
5875
5876    #[test]
5877    fn durable_conflict_outcome_and_resolution_survive_reopen() {
5878        let path = std::env::temp_dir().join(format!(
5879            "syncular-durable-outcome-{}.db",
5880            uuid::Uuid::new_v4()
5881        ));
5882        let schema = json!({
5883            "version": 1,
5884            "tables": [{
5885                "name": "tasks",
5886                "primaryKey": "id",
5887                "columns": [
5888                    { "name": "id", "type": "string", "nullable": false },
5889                    { "name": "project_id", "type": "string", "nullable": false }
5890                ],
5891                "scopes": [{ "pattern": "project:{project_id}" }]
5892            }]
5893        });
5894        let conflict = ConflictRecord {
5895            client_commit_id: "losing-commit".to_owned(),
5896            op_index: 0,
5897            table: "tasks".to_owned(),
5898            row_id: "t1".to_owned(),
5899            code: "sync.version_conflict".to_owned(),
5900            message: "stale base version".to_owned(),
5901            server_version: 2,
5902            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
5903            conflict_columns: vec!["project_id".to_owned()],
5904            operation: Some(CommitOperation {
5905                table: "tasks".to_owned(),
5906                row_id: "t1".to_owned(),
5907                op: "upsert".to_owned(),
5908                base_version: Some(1),
5909                values: Some(Map::from_iter([
5910                    ("id".into(), json!("t1")),
5911                    ("project_id".into(), json!("p1")),
5912                ])),
5913            }),
5914        };
5915        let failed_operations = vec![
5916            OutboxOp {
5917                upsert: true,
5918                table: "tasks".to_owned(),
5919                row_id: "t1".to_owned(),
5920                base_version: Some(1),
5921                values: Some(Map::from_iter([
5922                    ("id".into(), json!("t1")),
5923                    ("project_id".into(), json!("p1")),
5924                ])),
5925            },
5926            OutboxOp {
5927                upsert: true,
5928                table: "tasks".to_owned(),
5929                row_id: "status-event-1".to_owned(),
5930                base_version: Some(0),
5931                values: Some(Map::from_iter([
5932                    ("id".into(), json!("status-event-1")),
5933                    ("project_id".into(), json!("p1")),
5934                ])),
5935            },
5936        ];
5937
5938        {
5939            let mut first = SyncClient::open_path(
5940                "durable-native".to_owned(),
5941                &schema,
5942                ClientLimits::default(),
5943                path.to_str().expect("UTF-8 temp path"),
5944            )
5945            .expect("first open");
5946            first
5947                .begin_observation("test_outcome")
5948                .expect("begin outcome");
5949            first
5950                .persist_commit_outcome(
5951                    "losing-commit",
5952                    CommitOutcomeStatus::Conflict,
5953                    &[CommitOperationOutcome::Conflict {
5954                        conflict: conflict.clone(),
5955                    }],
5956                    Some(&failed_operations),
5957                )
5958                .expect("persist outcome");
5959            first.conflicts.push(conflict);
5960            first
5961                .finish_observation(
5962                    "test_outcome",
5963                    ChangeAccumulator {
5964                        conflicts: true,
5965                        outcomes: true,
5966                        ..ChangeAccumulator::default()
5967                    },
5968                )
5969                .expect("commit outcome");
5970        }
5971
5972        {
5973            let mut reopened = SyncClient::open_path(
5974                "durable-native".to_owned(),
5975                &schema,
5976                ClientLimits::default(),
5977                path.to_str().expect("UTF-8 temp path"),
5978            )
5979            .expect("reopen");
5980            assert_eq!(reopened.conflicts().len(), 1);
5981            assert_eq!(
5982                reopened.conflicts()[0].conflict_columns,
5983                vec!["project_id".to_owned()],
5984                "conflictColumns survives the journal round trip"
5985            );
5986            let outcome = reopened
5987                .commit_outcome("losing-commit")
5988                .expect("read outcome")
5989                .expect("outcome");
5990            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
5991            let operations = outcome.operations.expect("aggregate envelope");
5992            assert_eq!(operations.len(), 2);
5993            assert_eq!(operations[1].row_id, "status-event-1");
5994            let resolved = reopened
5995                .resolve_commit_outcome(ResolveCommitOutcomeInput {
5996                    client_commit_id: "losing-commit".to_owned(),
5997                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
5998                    replacement_client_commit_id: None,
5999                })
6000                .expect("resolve");
6001            assert_eq!(
6002                resolved.resolution,
6003                CommitOutcomeResolution::ResolvedKeepServer
6004            );
6005            assert!(reopened.conflicts().is_empty());
6006        }
6007
6008        let reopened = SyncClient::open_path(
6009            "durable-native".to_owned(),
6010            &schema,
6011            ClientLimits::default(),
6012            path.to_str().expect("UTF-8 temp path"),
6013        )
6014        .expect("second reopen");
6015        assert!(reopened.conflicts().is_empty());
6016        assert_eq!(
6017            reopened
6018                .commit_outcome("losing-commit")
6019                .expect("read outcome")
6020                .expect("outcome")
6021                .resolution,
6022            CommitOutcomeResolution::ResolvedKeepServer
6023        );
6024        drop(reopened);
6025        std::fs::remove_file(path).expect("remove temp database");
6026    }
6027
6028    #[test]
6029    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
6030        let path =
6031            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6032        let schema = json!({
6033            "version": 1,
6034            "tables": [{
6035                "name": "tasks",
6036                "primaryKey": "id",
6037                "columns": [
6038                    { "name": "id", "type": "string", "nullable": false },
6039                    { "name": "project_id", "type": "string", "nullable": false }
6040                ],
6041                "scopes": [{ "pattern": "project:{project_id}" }]
6042            }]
6043        });
6044        let mut client = SyncClient::open_path_with_identity(
6045            Some("sidecar-client".to_owned()),
6046            &schema,
6047            ClientLimits::default(),
6048            path.to_str().expect("UTF-8 temp path"),
6049        )
6050        .expect("open owner");
6051        let base = WindowBase {
6052            table: "tasks".to_owned(),
6053            variable: "project_id".to_owned(),
6054            fixed_scopes: Vec::new(),
6055            params: None,
6056        };
6057        client
6058            .set_window(&base, &["one".to_owned()])
6059            .expect("set window");
6060        client
6061            .mutate(vec![Mutation::Upsert {
6062                table: "tasks".to_owned(),
6063                values: Map::from_iter([
6064                    ("id".to_owned(), Value::from("t1")),
6065                    ("project_id".to_owned(), Value::from("one")),
6066                ]),
6067                base_version: None,
6068            }])
6069            .expect("local mutate");
6070
6071        let coverage = [WindowCoverage {
6072            base,
6073            units: vec!["one".to_owned(), "missing".to_owned()],
6074        }];
6075        let owner = client
6076            .query_snapshot(
6077                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6078                &[],
6079                &coverage,
6080                None,
6081            )
6082            .expect("owner snapshot");
6083        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6084        let sidecar = reader
6085            .query_snapshot(
6086                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6087                &[],
6088                &coverage,
6089            )
6090            .expect("sidecar snapshot");
6091
6092        assert_eq!(sidecar.revision, owner.revision);
6093        assert_eq!(sidecar.rows, owner.rows);
6094        assert_eq!(
6095            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
6096            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
6097        );
6098        assert_eq!(sidecar.revision, "2");
6099        assert_eq!(sidecar.rows[0]["id"], "t1");
6100        assert_eq!(sidecar.rows[0]["server_version"], -1);
6101        assert!(!sidecar.coverage.complete);
6102        assert_eq!(sidecar.coverage.pending.len(), 1);
6103        assert_eq!(sidecar.coverage.missing.len(), 1);
6104
6105        drop(reader);
6106        drop(client);
6107        std::fs::remove_file(path).expect("remove temp database");
6108    }
6109
6110    #[test]
6111    fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
6112        let path =
6113            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6114        let schema = json!({
6115            "version": 1,
6116            "tables": [{
6117                "name": "tasks",
6118                "primaryKey": "id",
6119                "columns": [
6120                    { "name": "id", "type": "string", "nullable": false },
6121                    { "name": "project_id", "type": "string", "nullable": false }
6122                ],
6123                "scopes": [{ "pattern": "project:{project_id}" }]
6124            }]
6125        });
6126        let mut client = SyncClient::open_path_with_identity(
6127            Some("sidecar-catchup".to_owned()),
6128            &schema,
6129            ClientLimits::default(),
6130            path.to_str().expect("UTF-8 temp path"),
6131        )
6132        .expect("open owner");
6133        client
6134            .subscribe(
6135                "s1".into(),
6136                "tasks".into(),
6137                vec![("project_id".into(), vec!["one".into()])],
6138                None,
6139            )
6140            .expect("subscribe");
6141        let commit_id = client
6142            .mutate(vec![Mutation::Upsert {
6143                table: "tasks".to_owned(),
6144                values: Map::from_iter([
6145                    ("id".to_owned(), Value::from("t1")),
6146                    ("project_id".to_owned(), Value::from("one")),
6147                ]),
6148                base_version: None,
6149            }])
6150            .expect("mutate");
6151        let revision = client.local_revision().to_string();
6152
6153        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6154        assert_eq!(reader.local_revision().expect("revision"), revision);
6155
6156        let read = reader
6157            .snapshot_read(&SnapshotReadRequest {
6158                statements: vec![
6159                    SnapshotStatement {
6160                        sql: "SELECT 1 AS n",
6161                        params: &[],
6162                    },
6163                    SnapshotStatement {
6164                        sql: "SELECT id FROM tasks ORDER BY id",
6165                        params: &[],
6166                    },
6167                ],
6168                coverage: &[],
6169                subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
6170                commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
6171            })
6172            .expect("snapshot read");
6173        assert_eq!(read.revision, revision);
6174        assert_eq!(read.queries.len(), 2);
6175        assert_eq!(read.queries[0][0]["n"], 1);
6176        assert_eq!(read.queries[1][0]["id"], "t1");
6177        match &read.subscriptions[0] {
6178            SubscriptionCatchup::Known {
6179                status,
6180                cursor,
6181                bootstrap_complete,
6182                known_pending_pages,
6183                ..
6184            } => {
6185                assert_eq!(status, "active");
6186                assert_eq!(*cursor, -1);
6187                assert!(!bootstrap_complete);
6188                assert!(known_pending_pages);
6189            }
6190            other => panic!("expected a known subscription: {other:?}"),
6191        }
6192        assert!(matches!(
6193            &read.subscriptions[1],
6194            SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
6195        ));
6196        assert!(matches!(
6197            &read.deliveries[0],
6198            CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
6199        ));
6200        assert!(matches!(
6201            &read.deliveries[1],
6202            CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
6203        ));
6204
6205        // A bad statement rolls the whole read back and releases the connection.
6206        assert!(reader
6207            .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
6208            .is_err());
6209        assert_eq!(
6210            reader.local_revision().expect("revision after rollback"),
6211            revision
6212        );
6213        // The read-only guard refuses a write on any statement.
6214        assert!(reader
6215            .query_snapshot("DELETE FROM tasks", &[], &[])
6216            .is_err());
6217
6218        // A corrupt revision marker fails typed, not as an invented zero.
6219        client
6220            .conn
6221            .execute(
6222                "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
6223                [],
6224            )
6225            .expect("corrupt revision");
6226        let failure = reader.local_revision().expect_err("corrupt revision fails");
6227        assert_eq!(failure.code, Some("sync.local_corrupt"));
6228
6229        drop(reader);
6230        drop(client);
6231        std::fs::remove_file(path).expect("remove temp database");
6232    }
6233
6234    #[test]
6235    fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
6236        let path =
6237            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6238        let schema = json!({
6239            "version": 1,
6240            "tables": [{
6241                "name": "tasks",
6242                "primaryKey": "id",
6243                "columns": [
6244                    { "name": "id", "type": "string", "nullable": false },
6245                    { "name": "project_id", "type": "string", "nullable": false }
6246                ],
6247                "scopes": [{ "pattern": "project:{project_id}" }]
6248            }]
6249        });
6250        let mut client = SyncClient::open_path_with_identity(
6251            Some("sidecar-delivery".to_owned()),
6252            &schema,
6253            ClientLimits::default(),
6254            path.to_str().expect("UTF-8 temp path"),
6255        )
6256        .expect("open owner");
6257        client.begin_observation("test_delivery").expect("observe");
6258        let operations = [OutboxOp {
6259            upsert: true,
6260            table: "tasks".to_owned(),
6261            row_id: "t1".to_owned(),
6262            base_version: Some(1),
6263            values: Some(Map::from_iter([
6264                ("id".into(), json!("t1")),
6265                ("project_id".into(), json!("p1")),
6266            ])),
6267        }];
6268        client
6269            .persist_commit_outcome(
6270                "conflict-commit",
6271                CommitOutcomeStatus::Conflict,
6272                &[CommitOperationOutcome::Conflict {
6273                    conflict: ConflictRecord {
6274                        client_commit_id: "conflict-commit".to_owned(),
6275                        op_index: 0,
6276                        table: "tasks".to_owned(),
6277                        row_id: "t1".to_owned(),
6278                        code: "sync.version_conflict".to_owned(),
6279                        message: "stale base version".to_owned(),
6280                        server_version: 2,
6281                        server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6282                        conflict_columns: vec!["project_id".to_owned()],
6283                        operation: None,
6284                    },
6285                }],
6286                Some(&operations),
6287            )
6288            .expect("persist conflict");
6289        client
6290            .persist_commit_outcome(
6291                "rejected-commit",
6292                CommitOutcomeStatus::Rejected,
6293                &[CommitOperationOutcome::Error {
6294                    rejection: RejectionRecord {
6295                        client_commit_id: "rejected-commit".to_owned(),
6296                        op_index: 0,
6297                        code: "sync.unknown_table".to_owned(),
6298                        message: "unknown table".to_owned(),
6299                        retryable: false,
6300                        details: None,
6301                        operation: None,
6302                    },
6303                }],
6304                Some(&operations),
6305            )
6306            .expect("persist rejection");
6307        client
6308            .finish_observation(
6309                "test_delivery",
6310                ChangeAccumulator {
6311                    conflicts: true,
6312                    outcomes: true,
6313                    ..ChangeAccumulator::default()
6314                },
6315            )
6316            .expect("commit outcomes");
6317
6318        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6319        match reader.commit_delivery("conflict-commit").expect("conflict") {
6320            CommitDelivery::Known { outcome, .. } => {
6321                assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6322                assert!(matches!(
6323                    outcome.results.as_slice(),
6324                    [CommitOperationOutcome::Conflict { conflict }]
6325                        if conflict.code == "sync.version_conflict"
6326                            && conflict.server_version == 2
6327                ));
6328                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
6329                assert!(
6330                    outcome.retained_rows.is_none(),
6331                    "the sidecar reports the persisted view, not owner-derived retained rows"
6332                );
6333            }
6334            other => panic!("expected a known conflict: {other:?}"),
6335        }
6336        match reader
6337            .commit_delivery("rejected-commit")
6338            .expect("rejection")
6339        {
6340            CommitDelivery::Known { outcome, .. } => {
6341                assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
6342                assert!(matches!(
6343                    outcome.results.as_slice(),
6344                    [CommitOperationOutcome::Error { rejection }]
6345                        if rejection.code == "sync.unknown_table"
6346                ));
6347                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
6348                assert!(outcome.retained_rows.is_none());
6349            }
6350            other => panic!("expected a known rejection: {other:?}"),
6351        }
6352        drop(reader);
6353        drop(client);
6354        std::fs::remove_file(path).expect("remove temp database");
6355    }
6356
6357    #[test]
6358    fn snapshot_read_isolates_a_concurrent_writer_commit() {
6359        use std::sync::mpsc::channel;
6360        let path = std::env::temp_dir().join(format!(
6361            "syncular-read-concurrent-{}.db",
6362            uuid::Uuid::new_v4()
6363        ));
6364        let schema = json!({
6365            "version": 1,
6366            "tables": [{
6367                "name": "tasks",
6368                "primaryKey": "id",
6369                "columns": [
6370                    { "name": "id", "type": "string", "nullable": false },
6371                    { "name": "project_id", "type": "string", "nullable": false }
6372                ],
6373                "scopes": [{ "pattern": "project:{project_id}" }]
6374            }]
6375        });
6376        let mut client = SyncClient::open_path_with_identity(
6377            Some("sidecar-concurrent".to_owned()),
6378            &schema,
6379            ClientLimits::default(),
6380            path.to_str().expect("UTF-8 temp path"),
6381        )
6382        .expect("open owner");
6383        client
6384            .mutate(vec![Mutation::Upsert {
6385                table: "tasks".to_owned(),
6386                values: Map::from_iter([
6387                    ("id".to_owned(), Value::from("t1")),
6388                    ("project_id".to_owned(), Value::from("one")),
6389                ]),
6390                base_version: None,
6391            }])
6392            .expect("seed row");
6393        let revision_before = client.local_revision().to_string();
6394
6395        // A writer on a second connection commits while the reader is between
6396        // statements; the hook rendezvous is a barrier, not a sleep.
6397        let (ready_tx, ready_rx) = channel::<()>();
6398        let (done_tx, done_rx) = channel::<()>();
6399        let writer_path = path.to_string_lossy().into_owned();
6400        let writer_schema = schema.clone();
6401        let writer = std::thread::spawn(move || {
6402            let mut writer = SyncClient::open_path_with_identity(
6403                Some("sidecar-concurrent".to_owned()),
6404                &writer_schema,
6405                ClientLimits::default(),
6406                &writer_path,
6407            )
6408            .expect("open writer");
6409            ready_rx.recv().expect("reader signalled");
6410            writer
6411                .mutate(vec![Mutation::Upsert {
6412                    table: "tasks".to_owned(),
6413                    values: Map::from_iter([
6414                        ("id".to_owned(), Value::from("t2")),
6415                        ("project_id".to_owned(), Value::from("one")),
6416                    ]),
6417                    base_version: None,
6418                }])
6419                .expect("writer commit");
6420            let _ = done_tx.send(());
6421        });
6422        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6423        SNAPSHOT_READ_HOOK.with(|slot| {
6424            *slot.borrow_mut() = Some(Box::new(move || {
6425                ready_tx.send(()).expect("signal writer");
6426                done_rx.recv().expect("writer finished");
6427            }));
6428        });
6429        let read = reader
6430            .snapshot_read(&SnapshotReadRequest {
6431                statements: vec![
6432                    SnapshotStatement {
6433                        sql: "SELECT count(*) AS n FROM tasks",
6434                        params: &[],
6435                    },
6436                    SnapshotStatement {
6437                        sql: "SELECT count(*) AS n FROM tasks",
6438                        params: &[],
6439                    },
6440                ],
6441                coverage: &[],
6442                subscriptions: Vec::new(),
6443                commit_ids: Vec::new(),
6444            })
6445            .expect("snapshot read");
6446        assert_eq!(
6447            read.queries[0][0]["n"], 1,
6448            "first statement sees the pre-commit row"
6449        );
6450        assert_eq!(
6451            read.queries[1][0]["n"], 1,
6452            "a later statement keeps the one read snapshot"
6453        );
6454        assert_eq!(
6455            read.revision, revision_before,
6456            "the read reports the snapshot revision, not the writer's"
6457        );
6458        writer.join().expect("join writer");
6459
6460        assert_ne!(
6461            reader.local_revision().expect("revision after commit"),
6462            revision_before,
6463            "the next read observes the committed revision"
6464        );
6465        let after = reader
6466            .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
6467            .expect("count after commit");
6468        assert_eq!(
6469            after.rows[0]["n"], 2,
6470            "the committed row is visible afterwards"
6471        );
6472
6473        drop(reader);
6474        drop(client);
6475        std::fs::remove_file(path).expect("remove temp database");
6476    }
6477
6478    #[test]
6479    fn snapshot_read_rejects_every_non_canonical_revision() {
6480        let path = std::env::temp_dir().join(format!(
6481            "syncular-read-revision-{}.db",
6482            uuid::Uuid::new_v4()
6483        ));
6484        let schema = json!({
6485            "version": 1,
6486            "tables": [{
6487                "name": "tasks",
6488                "primaryKey": "id",
6489                "columns": [
6490                    { "name": "id", "type": "string", "nullable": false },
6491                    { "name": "project_id", "type": "string", "nullable": false }
6492                ],
6493                "scopes": [{ "pattern": "project:{project_id}" }]
6494            }]
6495        });
6496        let client = SyncClient::open_path_with_identity(
6497            Some("sidecar-revision".to_owned()),
6498            &schema,
6499            ClientLimits::default(),
6500            path.to_str().expect("UTF-8 temp path"),
6501        )
6502        .expect("open owner");
6503        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6504        for bad in [
6505            "+1",
6506            "01",
6507            "-1",
6508            " 1",
6509            "1 ",
6510            "18446744073709551616",
6511            "1.0",
6512            "abc",
6513            "",
6514        ] {
6515            client
6516                .conn
6517                .execute(
6518                    "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
6519                    rusqlite::params![bad],
6520                )
6521                .expect("corrupt revision");
6522            let failure = reader.local_revision().expect_err("corrupt marker fails");
6523            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
6524        }
6525        for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
6526            client
6527                .conn
6528                .execute_batch(&format!(
6529                    "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
6530                ))
6531                .expect("corrupt stored revision type");
6532            assert_eq!(
6533                reader.local_revision().expect_err("invalid SQL field").code,
6534                Some("sync.local_corrupt")
6535            );
6536        }
6537        // The absent marker is the legacy zero, not corruption.
6538        client
6539            .conn
6540            .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
6541            .expect("delete marker");
6542        assert_eq!(reader.local_revision().expect("legacy zero"), "0");
6543
6544        drop(reader);
6545        drop(client);
6546        std::fs::remove_file(path).expect("remove temp database");
6547    }
6548
6549    #[test]
6550    fn snapshot_read_rejects_corrupt_subscription_state() {
6551        let path = std::env::temp_dir().join(format!(
6552            "syncular-read-substate-{}.db",
6553            uuid::Uuid::new_v4()
6554        ));
6555        let schema = json!({
6556            "version": 1,
6557            "tables": [{
6558                "name": "tasks",
6559                "primaryKey": "id",
6560                "columns": [
6561                    { "name": "id", "type": "string", "nullable": false },
6562                    { "name": "project_id", "type": "string", "nullable": false }
6563                ],
6564                "scopes": [{ "pattern": "project:{project_id}" }]
6565            }]
6566        });
6567        let mut client = SyncClient::open_path_with_identity(
6568            Some("sidecar-substate".to_owned()),
6569            &schema,
6570            ClientLimits::default(),
6571            path.to_str().expect("UTF-8 temp path"),
6572        )
6573        .expect("open owner");
6574        client
6575            .subscribe(
6576                "s1".into(),
6577                "tasks".into(),
6578                vec![("project_id".into(), vec!["one".into()])],
6579                None,
6580            )
6581            .expect("subscribe");
6582        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6583        for bad in [
6584            "not json".to_owned(),
6585            "{}".to_owned(),
6586            json!({ "status": "bogus", "cursor": 0 }).to_string(),
6587            json!({ "status": "active", "cursor": "x" }).to_string(),
6588            json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
6589            json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
6590        ] {
6591            client
6592                .conn
6593                .execute(
6594                    "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
6595                    rusqlite::params![bad],
6596                )
6597                .expect("corrupt subscription state");
6598            let failure = reader
6599                .subscription_catchup("s1")
6600                .expect_err("corrupt state fails");
6601            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
6602        }
6603
6604        for assignment in [
6605            "tbl=X'7461736b73'",
6606            "state_json=X'7b7d'",
6607            "state_json=CAST(X'FF' AS TEXT)",
6608        ] {
6609            client.conn.execute_batch(&format!(
6610                "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
6611            )).expect("corrupt stored subscription type");
6612            assert_eq!(
6613                reader
6614                    .subscription_catchup("s1")
6615                    .expect_err("invalid SQL field")
6616                    .code,
6617                Some("sync.local_corrupt")
6618            );
6619        }
6620
6621        drop(reader);
6622        drop(client);
6623        std::fs::remove_file(path).expect("remove temp database");
6624    }
6625
6626    #[test]
6627    fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
6628        let path = std::env::temp_dir().join(format!(
6629            "syncular-read-rollback-{}.db",
6630            uuid::Uuid::new_v4()
6631        ));
6632        let schema = json!({
6633            "version": 1,
6634            "tables": [{
6635                "name": "tasks",
6636                "primaryKey": "id",
6637                "columns": [
6638                    { "name": "id", "type": "string", "nullable": false },
6639                    { "name": "project_id", "type": "string", "nullable": false }
6640                ],
6641                "scopes": [{ "pattern": "project:{project_id}" }]
6642            }]
6643        });
6644        let mut client = SyncClient::open_path_with_identity(
6645            Some("sidecar-rollback".to_owned()),
6646            &schema,
6647            ClientLimits::default(),
6648            path.to_str().expect("UTF-8 temp path"),
6649        )
6650        .expect("open owner");
6651        let commit_id = client
6652            .mutate(vec![Mutation::Upsert {
6653                table: "tasks".to_owned(),
6654                values: Map::from_iter([
6655                    ("id".to_owned(), Value::from("t1")),
6656                    ("project_id".to_owned(), Value::from("one")),
6657                ]),
6658                base_version: None,
6659            }])
6660            .expect("seed row");
6661        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6662        let revision = reader.local_revision().expect("revision");
6663
6664        // The second statement fails: the whole read rolls back and the
6665        // connection releases, so the same reader still works afterwards.
6666        let failure = reader
6667            .snapshot_read(&SnapshotReadRequest {
6668                statements: vec![
6669                    SnapshotStatement {
6670                        sql: "SELECT id FROM tasks",
6671                        params: &[],
6672                    },
6673                    SnapshotStatement {
6674                        sql: "SELECT * FROM does_not_exist",
6675                        params: &[],
6676                    },
6677                ],
6678                coverage: &[],
6679                subscriptions: Vec::new(),
6680                commit_ids: vec![commit_id.clone()],
6681            })
6682            .expect_err("second statement fails");
6683        assert_eq!(failure.code, None);
6684        assert_eq!(
6685            reader.local_revision().expect("revision after rollback"),
6686            revision,
6687            "a rolled-back read consumes no revision"
6688        );
6689        assert!(
6690            !matches!(
6691                reader
6692                    .commit_delivery(&commit_id)
6693                    .expect("delivery after rollback"),
6694                CommitDelivery::Unknown { .. }
6695            ),
6696            "the same reader serves the next read after releasing the connection"
6697        );
6698
6699        // The read-only guard rejects a write in the first position too.
6700        let guard = reader
6701            .snapshot_read(&SnapshotReadRequest {
6702                statements: vec![SnapshotStatement {
6703                    sql: "DELETE FROM tasks",
6704                    params: &[],
6705                }],
6706                coverage: &[],
6707                subscriptions: Vec::new(),
6708                commit_ids: Vec::new(),
6709            })
6710            .expect_err("write guard");
6711        assert!(guard.message.contains("read-only"), "{}", guard.message);
6712
6713        drop(reader);
6714        drop(client);
6715        std::fs::remove_file(path).expect("remove temp database");
6716    }
6717
6718    #[test]
6719    fn owned_query_failures_classify_order_bound_and_clear() {
6720        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6721            rusqlite::ffi::Error::new(266),
6722            Some("private SQLite prose".to_owned()),
6723        ));
6724        assert_eq!(io.code, Some("client.storage_io"));
6725        assert_eq!(io.sqlite_code, Some(266));
6726        assert_eq!(io.message, "local SQLite storage I/O failed");
6727        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6728            rusqlite::ffi::Error::new(26),
6729            None,
6730        ));
6731        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
6732        assert!(!io.retryable());
6733        assert!(!corrupt.retryable());
6734
6735        // SQLITE_BUSY (5) and SQLITE_LOCKED (6), including extended codes, are
6736        // transient lock contention: classified busy and retryable.
6737        for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
6738            let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6739                rusqlite::ffi::Error::new(raw),
6740                Some("database is locked".to_owned()),
6741            ));
6742            assert_eq!(busy.code, Some("client.storage_busy"));
6743            assert_eq!(busy.sqlite_code, Some(extended));
6744            assert_eq!(busy.message, "local SQLite storage is busy");
6745            assert!(busy.retryable());
6746        }
6747        let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6748            rusqlite::ffi::Error::new(1),
6749            Some("generic SQLite prose".to_owned()),
6750        ));
6751        assert_eq!(unrelated.code, None);
6752        assert!(!unrelated.retryable());
6753
6754        let mut client = client();
6755        let generic = QueryReadFailure::from("private query prose".to_owned());
6756        client.record_query_read(
6757            QueryOwner {
6758                id: "first",
6759                tables: &["tasks", "tasks"],
6760            },
6761            Some(&generic),
6762        );
6763        client.record_query_read(
6764            QueryOwner {
6765                id: "second",
6766                tables: &["tasks"],
6767            },
6768            Some(&generic),
6769        );
6770        client.record_query_read(
6771            QueryOwner {
6772                id: "first",
6773                tables: &["docs"],
6774            },
6775            Some(&io),
6776        );
6777        let failures = &client
6778            .diagnostics_snapshot(&Default::default())
6779            .expect("diagnostics")
6780            .query_failures;
6781        assert_eq!(
6782            failures
6783                .iter()
6784                .map(|entry| entry.id.as_str())
6785                .collect::<Vec<_>>(),
6786            vec!["second", "first"]
6787        );
6788        assert_eq!(failures[1].tables, vec!["docs"]);
6789        assert_eq!(failures[1].code, "client.storage_io");
6790
6791        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
6792            client.record_query_read(
6793                QueryOwner {
6794                    id: &format!("query-{index}"),
6795                    tables: &["tasks"],
6796                },
6797                Some(&generic),
6798            );
6799        }
6800        let failures = &client
6801            .diagnostics_snapshot(&Default::default())
6802            .expect("bounded diagnostics")
6803            .query_failures;
6804        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
6805        assert_eq!(failures.last().expect("last failure").id, "query-256");
6806
6807        client
6808            .query_snapshot(
6809                "SELECT id FROM tasks",
6810                &[],
6811                &[],
6812                Some(QueryOwner {
6813                    id: "query-256",
6814                    tables: &["tasks"],
6815                }),
6816            )
6817            .expect("successful owned read");
6818        assert!(client
6819            .diagnostics_snapshot(&Default::default())
6820            .expect("cleared diagnostics")
6821            .query_failures
6822            .iter()
6823            .all(|entry| entry.id != "query-256"));
6824    }
6825
6826    #[test]
6827    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
6828        let mut client = client();
6829        let image = Connection::open_in_memory().unwrap();
6830        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
6831            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
6832            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
6833            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
6834            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
6835        let table = client.schema.table("tasks").unwrap().clone();
6836        let scopes = vec![("project_id".into(), vec!["p1".into()])];
6837        assert!(client
6838            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
6839            .is_err());
6840        assert!(client.conn.is_autocommit());
6841        assert_eq!(
6842            client
6843                .query("SELECT count(*) AS n FROM tasks", &[])
6844                .unwrap()[0]["n"],
6845            1024
6846        );
6847        assert_eq!(client.drain_change_batches().len(), 1);
6848        image
6849            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
6850            .unwrap();
6851        assert!(matches!(
6852            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
6853            Ok(2050)
6854        ));
6855        assert_eq!(
6856            client
6857                .query("SELECT count(*) AS n FROM tasks", &[])
6858                .unwrap()[0]["n"],
6859            2050
6860        );
6861        assert_eq!(client.drain_change_batches().len(), 3);
6862    }
6863
6864    #[test]
6865    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
6866        let mut client = client();
6867        let base = WindowBase {
6868            table: "tasks".into(),
6869            variable: "project_id".into(),
6870            fixed_scopes: Vec::new(),
6871            params: None,
6872        };
6873        client
6874            .set_window(&base, &["p1".into(), "p2".into()])
6875            .unwrap();
6876        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
6877            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
6878            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
6879            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
6880            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
6881        client.drain_change_batches();
6882        assert!(client.set_window(&base, &["p2".into()]).is_err());
6883        assert!(client.conn.is_autocommit());
6884        assert_eq!(
6885            client
6886                .query("SELECT count(*) AS n FROM tasks", &[])
6887                .unwrap()[0]["n"],
6888            1027
6889        );
6890        assert_eq!(client.window_state(&base).units, vec!["p2"]);
6891        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
6892        assert_eq!(client.drain_change_batches().len(), 1);
6893        client
6894            .conn
6895            .execute_batch("DROP TRIGGER interrupt_evict")
6896            .unwrap();
6897        client.drain_pending_evictions().unwrap();
6898        assert!(client.load_pending_evictions().unwrap().is_empty());
6899        assert_eq!(
6900            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
6901            "held"
6902        );
6903        assert_eq!(client.drain_change_batches().len(), 2);
6904    }
6905
6906    #[test]
6907    fn eviction_marker_failures_roll_back_window_changes() {
6908        let mut client = client();
6909        let base = WindowBase {
6910            table: "tasks".into(),
6911            variable: "project_id".into(),
6912            fixed_scopes: Vec::new(),
6913            params: None,
6914        };
6915        client.set_window(&base, &["p1".into()]).unwrap();
6916        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
6917            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
6918            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
6919            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
6920        let revision = client.local_revision();
6921        assert!(client.set_window(&base, &[]).is_err());
6922        assert!(client.conn.is_autocommit());
6923        assert_eq!(client.local_revision(), revision);
6924        assert_eq!(
6925            client
6926                .query("SELECT count(*) AS n FROM tasks", &[])
6927                .unwrap()[0]["n"],
6928            2050
6929        );
6930        assert_eq!(client.window_state(&base).units, vec!["p1"]);
6931        client.conn.execute_batch("DROP TRIGGER fail_marker;
6932            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
6933        assert!(client.set_window(&base, &[]).is_err());
6934        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
6935            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
6936        assert!(client.drain_pending_evictions().is_err());
6937        assert!(client.conn.is_autocommit());
6938        assert_eq!(
6939            client
6940                .query("SELECT count(*) AS n FROM tasks", &[])
6941                .unwrap()[0]["n"],
6942            2
6943        );
6944        assert!(client.set_window(&base, &["p1".into()]).is_err());
6945        assert!(client.conn.is_autocommit());
6946        assert!(client.window_state(&base).units.is_empty());
6947        assert!(client.subs.is_empty());
6948        assert_eq!(
6949            client
6950                .query(
6951                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
6952                    &[]
6953                )
6954                .unwrap()[0]["n"],
6955            1
6956        );
6957        client
6958            .conn
6959            .execute_batch("DROP TRIGGER fail_marker")
6960            .unwrap();
6961        client.set_window(&base, &["p1".into()]).unwrap();
6962        assert_eq!(client.window_state(&base).units, vec!["p1"]);
6963    }
6964
6965    #[test]
6966    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
6967        let mut client = client();
6968        client
6969            .conn
6970            .execute(
6971                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
6972                [],
6973            )
6974            .unwrap();
6975        assert_eq!(
6976            client.drain_pending_evictions().unwrap_err(),
6977            "sync.local_corrupt: invalid pending eviction scopes"
6978        );
6979        assert!(client.conn.is_autocommit());
6980        assert!(client.enqueue_startup_sync_if_needed().is_err());
6981    }
6982
6983    #[test]
6984    fn image_chunks_reject_null_primary_keys() {
6985        let mut client = client();
6986        let image = Connection::open_in_memory().unwrap();
6987        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
6988            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
6989            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
6990            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
6991            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
6992        let table = client.schema.table("tasks").unwrap().clone();
6993        assert!(client
6994            .apply_sqlite_image(
6995                &image,
6996                &table,
6997                true,
6998                &[("project_id".into(), vec!["p1".into()])],
6999                1025,
7000                7,
7001                "digest"
7002            )
7003            .is_err());
7004        assert!(client.conn.is_autocommit());
7005        assert_eq!(
7006            client
7007                .query("SELECT count(*) AS n FROM tasks", &[])
7008                .unwrap()[0]["n"],
7009            0
7010        );
7011    }
7012
7013    #[test]
7014    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
7015        for images in [false, true] {
7016            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7017                "name":name,"primaryKey":"id","columns":[
7018                    {"name":"id","type":"string","nullable":false},
7019                    {"name":"project_id","type":"string","nullable":false},
7020                    {"name":"title","type":"string","nullable":false}],
7021                "scopes":[{"pattern":"project:{project_id}"}],
7022                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
7023                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7024            })))});
7025            let mut actual =
7026                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
7027            let mut reference =
7028                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
7029            let scopes = vec![("project_id".into(), vec!["p1".into()])];
7030            for client in [&mut actual, &mut reference] {
7031                client
7032                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
7033                    .unwrap();
7034                client.subs[0].effective = Some(scopes.clone());
7035                for (table, id, title) in [
7036                    ("tasks", "a", "original"),
7037                    ("tasks", "b", "occupied"),
7038                    ("tasks", "c", "deleted"),
7039                    ("notes", "n", "untouched"),
7040                ] {
7041                    client
7042                        .write_base_row(
7043                            table,
7044                            &vec![
7045                                Some(ColumnValue::String(id.into())),
7046                                Some(ColumnValue::String("p1".into())),
7047                                Some(ColumnValue::String(title.into())),
7048                            ],
7049                            1,
7050                        )
7051                        .unwrap();
7052                }
7053                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7054                client
7055                    .mutate(vec![
7056                        Mutation::Upsert {
7057                            table: "tasks".into(),
7058                            values: Map::from_iter([
7059                                ("id".into(), Value::from("a")),
7060                                ("project_id".into(), Value::from("p1")),
7061                                ("title".into(), Value::from("available")),
7062                            ]),
7063                            base_version: None,
7064                        },
7065                        Mutation::Delete {
7066                            table: "tasks".into(),
7067                            row_id: "c".into(),
7068                            base_version: None,
7069                        },
7070                        Mutation::Upsert {
7071                            table: "tasks".into(),
7072                            values: Map::from_iter([
7073                                ("id".into(), Value::from("local")),
7074                                ("project_id".into(), Value::from("p1")),
7075                                ("title".into(), Value::from("local value")),
7076                            ]),
7077                            base_version: None,
7078                        },
7079                        Mutation::Upsert {
7080                            table: "notes".into(),
7081                            values: Map::from_iter([
7082                                ("id".into(), Value::from("n")),
7083                                ("project_id".into(), Value::from("p1")),
7084                                ("title".into(), Value::from("pending note")),
7085                            ]),
7086                            base_version: None,
7087                        },
7088                    ])
7089                    .unwrap();
7090            }
7091            let pending = actual.pending_commit_ids();
7092            actual.overlay_rebuild_count.set(0);
7093            for (id, title) in [
7094                ("b", "available"),
7095                ("b", "occupied"),
7096                ("a", "server original"),
7097                ("d", "local value"),
7098                ("d", "released"),
7099                ("c", "redelivered"),
7100            ] {
7101                let table = actual.schema.table("tasks").unwrap().clone();
7102                let values = vec![
7103                    Some(ColumnValue::String(id.into())),
7104                    Some(ColumnValue::String("p1".into())),
7105                    Some(ColumnValue::String(title.into())),
7106                ];
7107                if images {
7108                    let image = Connection::open_in_memory().unwrap();
7109                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7110                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7111                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7112                    image
7113                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
7114                        .unwrap();
7115                    assert!(matches!(
7116                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
7117                        Ok(1)
7118                    ));
7119                } else {
7120                    let segment = RowsSegment {
7121                        table: "tasks".into(),
7122                        schema_version: 1,
7123                        columns: table.wire_columns.clone(),
7124                        blocks: vec![vec![ssp2::segment::SegmentRow {
7125                            server_version: 2,
7126                            values: values.clone(),
7127                        }]],
7128                    };
7129                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
7130                }
7131                reference.write_base_row("tasks", &values, 2).unwrap();
7132                reference.rebuild_overlay().expect("rebuild overlay");
7133                for table in ["tasks", "notes"] {
7134                    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")] {
7135                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
7136                    }
7137                }
7138                assert_eq!(actual.pending_commit_ids(), pending);
7139            }
7140            assert_eq!(
7141                actual.overlay_rebuild_count.get(),
7142                0,
7143                "each chunk must leave unrelated tables alone"
7144            );
7145            let before = actual
7146                .query("SELECT * FROM tasks ORDER BY id", &[])
7147                .unwrap();
7148            let fts_before = actual
7149                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7150                .unwrap();
7151            let revision = actual.local_revision();
7152            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
7153            let table = actual.schema.table("tasks").unwrap().clone();
7154            let segment = RowsSegment {
7155                table: "tasks".into(),
7156                schema_version: 1,
7157                columns: table.wire_columns.clone(),
7158                blocks: vec![vec![ssp2::segment::SegmentRow {
7159                    server_version: 3,
7160                    values: vec![
7161                        Some(ColumnValue::String("b".into())),
7162                        Some(ColumnValue::String("p1".into())),
7163                        Some(ColumnValue::String("fresh".into())),
7164                    ],
7165                }]],
7166            };
7167            assert!(actual.apply_segment(0, &segment, false).is_err());
7168            assert!(actual.conn.is_autocommit());
7169            assert_eq!(
7170                actual
7171                    .query("SELECT * FROM tasks ORDER BY id", &[])
7172                    .unwrap(),
7173                before
7174            );
7175            assert_eq!(
7176                actual
7177                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7178                    .unwrap(),
7179                fts_before
7180            );
7181            assert_eq!(actual.local_revision(), revision);
7182            assert_eq!(actual.pending_commit_ids(), pending);
7183        }
7184    }
7185
7186    #[test]
7187    fn import_reconciliation_preserves_typed_pending_primary_keys() {
7188        for (kind, a, b) in [
7189            ("string", json!("a"), json!("b")),
7190            ("integer", json!(1), json!(2)),
7191            ("boolean", json!(true), json!(false)),
7192            ("json", json!("1"), json!("2")),
7193        ] {
7194            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}]}]});
7195            for images in [false, true] {
7196                let mut client =
7197                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
7198                let table = client.schema.table("tasks").unwrap().clone();
7199                client
7200                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
7201                    .unwrap();
7202                for (id, title) in [(&a, "original"), (&b, "occupied")] {
7203                    client
7204                        .write_base_row(
7205                            "tasks",
7206                            &vec![
7207                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
7208                                Some(ColumnValue::String(title.into())),
7209                            ],
7210                            1,
7211                        )
7212                        .unwrap();
7213                }
7214                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7215                client
7216                    .mutate(vec![Mutation::Upsert {
7217                        table: "tasks".into(),
7218                        values: Map::from_iter([
7219                            ("id".into(), a.clone()),
7220                            ("title".into(), json!("available")),
7221                        ]),
7222                        base_version: None,
7223                    }])
7224                    .unwrap();
7225                client.overlay_rebuild_count.set(0);
7226                for (title, expected) in [("available", "original"), ("occupied", "available")] {
7227                    let values = vec![
7228                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
7229                        Some(ColumnValue::String(title.into())),
7230                    ];
7231                    if images {
7232                        let image = Connection::open_in_memory().unwrap();
7233                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7234                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7235                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7236                        image
7237                            .execute(
7238                                "INSERT INTO tasks VALUES (?1,?2,2)",
7239                                rusqlite::params![RowParam::Cell(&values[0]), title],
7240                            )
7241                            .unwrap();
7242                        assert!(
7243                            matches!(
7244                                client.apply_sqlite_image(
7245                                    &image,
7246                                    &table,
7247                                    false,
7248                                    &[],
7249                                    1,
7250                                    7,
7251                                    "digest"
7252                                ),
7253                                Ok(1)
7254                            ),
7255                            "{kind}, images={images}"
7256                        );
7257                    } else {
7258                        let segment = RowsSegment {
7259                            table: "tasks".into(),
7260                            schema_version: 1,
7261                            columns: table.wire_columns.clone(),
7262                            blocks: vec![vec![ssp2::segment::SegmentRow {
7263                                server_version: 2,
7264                                values,
7265                            }]],
7266                        };
7267                        assert!(
7268                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
7269                            "{kind}"
7270                        );
7271                    }
7272                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
7273                    let actual: String = client
7274                        .conn
7275                        .query_row(
7276                            "SELECT title FROM tasks WHERE id=?1",
7277                            [RowParam::Cell(&key)],
7278                            |row| row.get(0),
7279                        )
7280                        .unwrap();
7281                    assert_eq!(actual, expected, "{kind}, images={images}");
7282                }
7283                assert_eq!(client.overlay_rebuild_count.get(), 0);
7284            }
7285        }
7286    }
7287
7288    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
7289        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7290            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
7291            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
7292            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7293        })))});
7294        let mut client =
7295            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
7296        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();
7297        client.rebuild_overlay().expect("rebuild overlay");
7298        client
7299            .mutate(vec![Mutation::Upsert {
7300                table: "tasks".into(),
7301                values: Map::from_iter([
7302                    ("id".into(), json!("offline")),
7303                    ("project_id".into(), json!("p1")),
7304                    ("title".into(), json!("pending edit")),
7305                ]),
7306                base_version: None,
7307            }])
7308            .unwrap();
7309        let image = Connection::open_in_memory().unwrap();
7310        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7311            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
7312        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();
7313        image
7314            .execute(
7315                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
7316                [rows],
7317            )
7318            .unwrap();
7319        client.overlay_rebuild_count.set(0);
7320        let table = client.schema.table("tasks").unwrap().clone();
7321        (client, image, table)
7322    }
7323
7324    #[test]
7325    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
7326        let (mut client, image, table) = unique_import_fixture(1);
7327        image
7328            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
7329            .unwrap();
7330        client
7331            .conn
7332            .execute_batch("PRAGMA max_page_count = 1")
7333            .unwrap();
7334        let effective = [("project_id".into(), vec!["p1".into()])];
7335        assert!(client
7336            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
7337            .is_err());
7338        let failure = client.storage_failure.borrow().clone().unwrap();
7339        assert_eq!(failure.code, Some("client.storage_full"));
7340        assert_eq!(failure.sqlite_code, Some(13));
7341        assert!(!failure.message.contains("rollback"));
7342        assert!(client.conn.is_autocommit());
7343        client
7344            .conn
7345            .execute_batch("PRAGMA max_page_count = 1073741823")
7346            .unwrap();
7347        assert!(matches!(
7348            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
7349            Ok(1)
7350        ));
7351        assert!(client.conn.is_autocommit());
7352    }
7353
7354    #[test]
7355    fn authoring_storage_failures_are_structured_and_scoped() {
7356        let path = std::env::temp_dir().join(format!(
7357            "syncular-authoring-failure-{}.db",
7358            uuid::Uuid::new_v4()
7359        ));
7360        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7361            {"name":"id","type":"string","nullable":false},
7362            {"name":"project_id","type":"string","nullable":false},
7363            {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
7364        let mut client = SyncClient::open_path(
7365            "authoring".into(),
7366            &schema,
7367            ClientLimits::default(),
7368            path.to_str().unwrap(),
7369        )
7370        .expect("open client");
7371        client.create_synced_tables().expect("create synced tables");
7372        // Return BUSY immediately rather than waiting out the file timeout.
7373        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
7374
7375        let upsert = |id: &str| {
7376            vec![Mutation::Upsert {
7377                table: "tasks".into(),
7378                values: Map::from_iter([
7379                    ("id".into(), json!(id)),
7380                    ("project_id".into(), json!("p1")),
7381                    ("title".into(), json!("first")),
7382                ]),
7383                base_version: None,
7384            }]
7385        };
7386
7387        let locker = Connection::open(&path).expect("second connection");
7388        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
7389        let busy = client.mutate(upsert("t1")).expect_err("busy write");
7390        assert_eq!(busy.code, "client.storage_busy");
7391        assert!(busy.retryable);
7392        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
7393        assert!(client.pending_commit_ids().is_empty());
7394        assert_eq!(client.local_revision(), 0);
7395
7396        // A stale classified failure must not classify a later unrelated one.
7397        let invalid = client.mutate(Vec::new()).expect_err("empty commit");
7398        assert_eq!(invalid.code, "sync.invalid_request");
7399        assert_eq!(invalid.message, "the authoring request is invalid");
7400        assert!(
7401            invalid.details.as_ref().unwrap()["legacyCause"]
7402                .as_str()
7403                .unwrap()
7404                .contains("at least one operation"),
7405            "{invalid:?}"
7406        );
7407
7408        locker.execute_batch("ROLLBACK").unwrap();
7409        let id = client.mutate(upsert("t1")).expect("retry after busy");
7410        assert_eq!(client.pending_commit_ids(), vec![id]);
7411        assert_eq!(client.local_revision(), 1);
7412
7413        // A later storage failure reports its own code and details.
7414        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
7415        let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
7416        assert_eq!(busy_again.code, "client.storage_busy");
7417        assert!(busy_again.retryable);
7418        locker.execute_batch("ROLLBACK").unwrap();
7419
7420        // Non-retryable classified failures keep their sqlite evidence too.
7421        let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7422            rusqlite::ffi::Error::new(13),
7423            Some("database or disk is full".to_owned()),
7424        )));
7425        assert_eq!(full.code, "client.storage_full");
7426        assert!(!full.retryable);
7427        assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
7428
7429        drop(locker);
7430        drop(client);
7431        let _ = std::fs::remove_file(&path);
7432    }
7433
7434    #[test]
7435    fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
7436        let path = std::env::temp_dir().join(format!(
7437            "syncular-caller-values-{}.db",
7438            uuid::Uuid::new_v4()
7439        ));
7440        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7441            {"name":"id","type":"string","nullable":false},
7442            {"name":"project_id","type":"string","nullable":false},
7443            {"name":"title","type":"string","nullable":false},
7444            {"name":"priority","type":"integer","nullable":false},
7445            {"name":"payload","type":"bytes","nullable":false},
7446            {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
7447        let mut client = SyncClient::open_path(
7448            "caller-values".into(),
7449            &schema,
7450            ClientLimits::default(),
7451            path.to_str().unwrap(),
7452        )
7453        .expect("open client");
7454        client.create_synced_tables().expect("create synced tables");
7455
7456        let values = |id: &str| {
7457            Map::from_iter([
7458                ("id".to_owned(), json!(id)),
7459                ("project_id".to_owned(), json!("p1")),
7460                ("title".to_owned(), json!("first")),
7461                ("priority".to_owned(), json!(1)),
7462                ("payload".to_owned(), json!({"$bytes": "0a"})),
7463            ])
7464        };
7465        let upsert = |values: Map<String, Value>| {
7466            vec![Mutation::Upsert {
7467                table: "tasks".into(),
7468                values,
7469                base_version: None,
7470            }]
7471        };
7472        let seed = client.mutate(upsert(values("t1"))).expect("seed row");
7473        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
7474
7475        // §6.1/§7.1: every caller value the authoring seam rejects reports the
7476        // same stable identity instead of the internal-failure default, keeps
7477        // the dynamic cause in `details.legacyCause`, and leaves the durable
7478        // outbox, the local revision and the projection untouched.
7479        for (label, override_value, cause) in [
7480            ("unknown column", ("nope", json!(1)), "unknown column"),
7481            (
7482                "internal sync column",
7483                ("_sync_version", json!(1)),
7484                "internal sync column",
7485            ),
7486            ("wrong value type", ("title", json!(7)), "expected a string"),
7487            (
7488                "malformed bytes envelope",
7489                ("payload", json!({"$bytes": "zz"})),
7490                "expected exactly",
7491            ),
7492            (
7493                "odd-length bytes envelope",
7494                ("payload", json!({"$bytes": "abc"})),
7495                "expected exactly",
7496            ),
7497            (
7498                "plus-sign bytes envelope",
7499                ("payload", json!({"$bytes": "+a"})),
7500                "expected exactly",
7501            ),
7502            (
7503                "bytes envelope with a second key",
7504                ("payload", json!({"$bytes": "0a", "extra": 1})),
7505                "expected exactly",
7506            ),
7507            (
7508                "no bytes envelope",
7509                ("payload", json!({"nested": true})),
7510                "expected exactly",
7511            ),
7512        ] {
7513            let mut full = values("t2");
7514            full.insert(override_value.0.to_owned(), override_value.1);
7515            let error = client.mutate(upsert(full)).expect_err(label);
7516            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
7517            assert_eq!(error.message, "the authoring request is invalid", "{label}");
7518            assert!(!error.retryable, "{label}");
7519            let legacy = error.details.as_ref().unwrap()["legacyCause"]
7520                .as_str()
7521                .unwrap();
7522            assert!(
7523                legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
7524                "{label}: {legacy}"
7525            );
7526            assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
7527            assert_eq!(client.local_revision(), 1, "{label}");
7528            assert_eq!(
7529                client.query("SELECT * FROM tasks", &[]).unwrap(),
7530                rows,
7531                "{label}"
7532            );
7533        }
7534
7535        // A full-row upsert presents every column, so a missing required one is
7536        // the caller's failure too.
7537        let mut missing = values("t3");
7538        missing.remove("payload");
7539        let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
7540        assert_eq!(missing_error.code, "sync.invalid_request");
7541        assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
7542            .as_str()
7543            .unwrap()
7544            .contains("is not nullable"));
7545
7546        // A primary key the wire cannot render is rejected before any op is
7547        // recorded.
7548        let mut bad_row_id = values("t4");
7549        bad_row_id.insert("id".to_owned(), json!({"nested": true}));
7550        let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
7551        assert_eq!(row_id_error.code, "sync.invalid_request");
7552
7553        // The sparse route classifies the same value failures.
7554        for (label, partial, cause) in [
7555            (
7556                "patch wrong value type",
7557                ("priority", json!("high")),
7558                "expected an integer",
7559            ),
7560            ("patch unknown column", ("nope", json!(1)), "unknown column"),
7561        ] {
7562            let error = client
7563                .patch(
7564                    "tasks",
7565                    "t1",
7566                    Map::from_iter([(partial.0.to_owned(), partial.1)]),
7567                    None,
7568                )
7569                .expect_err(label);
7570            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
7571            assert_eq!(error.message, "the authoring request is invalid", "{label}");
7572            assert!(
7573                error.details.as_ref().unwrap()["legacyCause"]
7574                    .as_str()
7575                    .unwrap()
7576                    .contains(cause),
7577                "{label}"
7578            );
7579        }
7580        assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
7581        assert_eq!(client.local_revision(), 1);
7582        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
7583
7584        // Nullable explicit null, an empty envelope, and uppercase digits stay
7585        // accepted; the stored payload is the decoded byte sequence.
7586        for (label, payload) in [
7587            ("explicit null", json!(null)),
7588            ("empty envelope", json!({"$bytes": ""})),
7589            ("uppercase envelope", json!({"$bytes": "0AFF"})),
7590        ] {
7591            let mut accepted = values("t-ok");
7592            accepted.insert("extra".to_owned(), payload);
7593            let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
7594                panic!("{label} must be accepted: {error:?}");
7595            });
7596            assert!(!encoded.is_empty(), "{label}");
7597        }
7598
7599        // Nothing is poisoned: the next valid authoring call succeeds.
7600        let next = client
7601            .mutate(upsert(values("t5")))
7602            .expect("valid after failures");
7603        assert_ne!(next, seed);
7604
7605        drop(client);
7606        let _ = std::fs::remove_file(&path);
7607    }
7608
7609    #[test]
7610    fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
7611        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7612            {"name":"id","type":"string","nullable":false},
7613            {"name":"project_id","type":"string","nullable":false},
7614            {"name":"payload","type":"bytes","nullable":true}],
7615            "scopes":[{"pattern":"project:{project_id}"}]}]});
7616        let open = |path: &std::path::Path| {
7617            SyncClient::open_path(
7618                "legacy-values".into(),
7619                &schema,
7620                ClientLimits::default(),
7621                path.to_str().unwrap(),
7622            )
7623            .expect("open client")
7624        };
7625        let row = |id: &str| {
7626            Map::from_iter([
7627                ("id".to_owned(), json!(id)),
7628                ("project_id".to_owned(), json!("p1")),
7629                ("payload".to_owned(), json!({"$bytes": "0a"})),
7630            ])
7631        };
7632        let upsert = |values| {
7633            vec![Mutation::Upsert {
7634                table: "tasks".into(),
7635                values,
7636                base_version: None,
7637            }]
7638        };
7639        for (label, stored_cell, representable) in [
7640            ("plus-sign envelope", json!({"$bytes": "+a"}), false),
7641            ("non-hex envelope", json!({"$bytes": "zz"}), false),
7642            ("odd-length envelope", json!({"$bytes": "abc"}), false),
7643            ("no envelope", json!({"nested": true}), false),
7644            // A wrong declared type and a stored null for a non-nullable column
7645            // are journalable as stored, so their rejection keeps the operation
7646            // envelope.
7647            ("wrong declared type", json!("high"), true),
7648            ("stored null for a non-nullable column", json!(null), true),
7649        ] {
7650            let path = std::env::temp_dir().join(format!(
7651                "syncular-legacy-values-{}-{}.db",
7652                label.replace(' ', "-"),
7653                uuid::Uuid::new_v4()
7654            ));
7655            let mut client = open(&path);
7656            client.create_synced_tables().expect("create synced tables");
7657            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
7658            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
7659            drop(client);
7660
7661            // The stored intent an earlier version accepted.
7662            let connection = Connection::open(&path).expect("raw connection");
7663            let (column, cell) = if stored_cell.is_null() {
7664                ("project_id", stored_cell.clone())
7665            } else {
7666                ("payload", stored_cell.clone())
7667            };
7668            let mut planted_values = json!({
7669                "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
7670            });
7671            planted_values[column] = cell;
7672            let planted = json!([{
7673                "op": "upsert",
7674                "table": "tasks",
7675                "rowId": "t1",
7676                "baseVersion": null,
7677                "values": planted_values
7678            }]);
7679            connection
7680                .execute(
7681                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
7682                    rusqlite::params![planted.to_string(), stale],
7683                )
7684                .expect("plant legacy intent");
7685            drop(connection);
7686
7687            // Reopen: the startup reconciliation recovers the commit before any
7688            // replay, so the observable phase matches the browser core.
7689            let reopened = open(&path);
7690            assert_eq!(
7691                reopened.pending_commit_ids(),
7692                vec![successor.clone()],
7693                "{label}"
7694            );
7695            let rejection = reopened
7696                .rejections()
7697                .iter()
7698                .find(|rejection| rejection.client_commit_id == stale)
7699                .unwrap_or_else(|| panic!("{label}: rejection"));
7700            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
7701            assert_eq!(
7702                rejection.message, "the persisted commit carries values the current codec refuses",
7703                "{label}"
7704            );
7705            assert_eq!(rejection.operation.is_some(), representable, "{label}");
7706            assert_eq!(
7707                rejection
7708                    .details
7709                    .as_ref()
7710                    .and_then(|details| details.reason.as_deref()),
7711                Some(INVALID_STORED_VALUES_REASON),
7712                "{label}"
7713            );
7714            // The rejection stays readable after a further reopen.
7715            let journal = reopened
7716                .commit_outcome(&stale)
7717                .expect("read outcome")
7718                .expect("journal entry");
7719            assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
7720            drop(reopened);
7721            let mut reopened = open(&path);
7722            let reread = reopened
7723                .commit_outcome(&stale)
7724                .expect("read outcome")
7725                .expect("journal entry");
7726            assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
7727
7728            // The legacy row's optimistic projection is gone, the successor
7729            // survives and still encodes, and the round path keeps the queue.
7730            assert_eq!(
7731                reopened
7732                    .query("SELECT id FROM tasks ORDER BY id", &[])
7733                    .unwrap(),
7734                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
7735                "{label}"
7736            );
7737            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
7738            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
7739            assert!(!reopened
7740                .drop_unencodable_outbox(false, None)
7741                .expect("idempotent prepass"));
7742            let mut transport = CountingRealtimeTransport::default();
7743            let outcome = reopened.sync(&mut transport);
7744            assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
7745            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
7746            let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
7747            assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
7748            assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
7749            drop(reopened);
7750            let _ = std::fs::remove_file(&path);
7751        }
7752    }
7753
7754    #[test]
7755    fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
7756        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7757            {"name":"id","type":"string","nullable":false},
7758            {"name":"project_id","type":"string","nullable":false},
7759            {"name":"payload","type":"bytes","nullable":true}],
7760            "scopes":[{"pattern":"project:{project_id}"}]}]});
7761        for (label, column, planted) in [
7762            ("malformed envelope", "payload", json!({"$bytes": "zz"})),
7763            (
7764                "stored null for a non-nullable column",
7765                "project_id",
7766                json!(null),
7767            ),
7768            ("wrong declared type", "payload", json!(7)),
7769        ] {
7770            let path = std::env::temp_dir().join(format!(
7771                "syncular-shared-row-{}-{}.db",
7772                label.replace(' ', "-"),
7773                uuid::Uuid::new_v4()
7774            ));
7775            let open = |path: &std::path::Path| {
7776                SyncClient::open_path(
7777                    "shared-row".into(),
7778                    &schema,
7779                    ClientLimits::default(),
7780                    path.to_str().unwrap(),
7781                )
7782                .expect("open client")
7783            };
7784            let row = |id: &str| {
7785                Map::from_iter([
7786                    ("id".to_owned(), json!(id)),
7787                    ("project_id".to_owned(), json!("p1")),
7788                    ("payload".to_owned(), json!({"$bytes": "0a"})),
7789                ])
7790            };
7791            let upsert = |values| {
7792                vec![Mutation::Upsert {
7793                    table: "tasks".into(),
7794                    values,
7795                    base_version: None,
7796                }]
7797            };
7798            let mut client = open(&path);
7799            client.create_synced_tables().expect("create synced tables");
7800            let older = client.mutate(upsert(row("t1"))).expect("older commit");
7801            let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
7802            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
7803            drop(client);
7804
7805            let connection = Connection::open(&path).expect("raw connection");
7806            for commit_id in [&older, &newer] {
7807                let mut values = row("t1");
7808                values.insert(column.to_owned(), planted.clone());
7809                let planted_ops = json!([{
7810                    "op": "upsert",
7811                    "table": "tasks",
7812                    "rowId": "t1",
7813                    "baseVersion": null,
7814                    "values": values
7815                }]);
7816                connection
7817                    .execute(
7818                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
7819                        rusqlite::params![planted_ops.to_string(), commit_id],
7820                    )
7821                    .expect("plant legacy intent");
7822            }
7823            drop(connection);
7824
7825            // Startup recovery drops both, FIFO, and publishes one batch.
7826            let mut reopened = open(&path);
7827            assert_eq!(
7828                reopened.pending_commit_ids(),
7829                vec![successor.clone()],
7830                "{label}"
7831            );
7832            assert_eq!(
7833                reopened
7834                    .rejections()
7835                    .iter()
7836                    .map(|rejection| rejection.client_commit_id.clone())
7837                    .collect::<Vec<_>>(),
7838                vec![older.clone(), newer.clone()],
7839                "{label}"
7840            );
7841            assert!(
7842                reopened
7843                    .rejections()
7844                    .iter()
7845                    .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
7846                        && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
7847                            == Some(INVALID_STORED_VALUES_REASON)),
7848                "{label}"
7849            );
7850            for commit_id in [&older, &newer] {
7851                assert!(
7852                    matches!(
7853                        reopened
7854                            .commit_outcome(commit_id)
7855                            .expect("outcome")
7856                            .map(|o| o.status),
7857                        Some(CommitOutcomeStatus::Rejected)
7858                    ),
7859                    "{label}"
7860                );
7861            }
7862            assert_eq!(
7863                reopened
7864                    .query("SELECT id FROM tasks ORDER BY id", &[])
7865                    .unwrap(),
7866                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
7867                "{label}"
7868            );
7869            let revision = reopened.local_revision();
7870            let batches = reopened.drain_change_batches();
7871            assert!(
7872                batches.iter().any(|batch| batch.rejections_changed
7873                    && batch.outcomes_changed
7874                    && batch.tables.iter().any(|change| change.table == "tasks")
7875                    && batch.revision == revision.to_string()),
7876                "{label}: {batches:?}"
7877            );
7878            drop(reopened);
7879
7880            // An ordinary reopen with nothing to recover publishes nothing and
7881            // leaves the revision alone.
7882            let mut stable = open(&path);
7883            assert_eq!(stable.local_revision(), revision, "{label}");
7884            assert!(stable.drain_change_batches().is_empty(), "{label}");
7885            assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
7886            drop(stable);
7887            let _ = std::fs::remove_file(&path);
7888        }
7889    }
7890
7891    #[test]
7892    fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
7893        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7894            {"name":"id","type":"string","nullable":false},
7895            {"name":"project_id","type":"string","nullable":false},
7896            {"name":"payload","type":"bytes","nullable":true}],
7897            "scopes":[{"pattern":"project:{project_id}"}]}]});
7898        for (label, second_op) in [
7899            (
7900                "delete carrying values",
7901                json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
7902            ),
7903            (
7904                "baseVersion beyond the safe bound",
7905                json!({"op": "upsert", "table": "tasks", "rowId": "t9",
7906                    "baseVersion": MAX_JS_SAFE_INTEGER + 1,
7907                    "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
7908            ),
7909        ] {
7910            let path = std::env::temp_dir().join(format!(
7911                "syncular-journal-shape-{}-{}.db",
7912                label.replace(' ', "-"),
7913                uuid::Uuid::new_v4()
7914            ));
7915            let open = |path: &std::path::Path| {
7916                SyncClient::open_path(
7917                    "journal-shape".into(),
7918                    &schema,
7919                    ClientLimits::default(),
7920                    path.to_str().unwrap(),
7921                )
7922                .expect("open client")
7923            };
7924            let values = |id: &str| {
7925                Map::from_iter([
7926                    ("id".to_owned(), json!(id)),
7927                    ("project_id".to_owned(), json!("p1")),
7928                    ("payload".to_owned(), json!({"$bytes": "0a"})),
7929                ])
7930            };
7931            let upsert = |values| {
7932                vec![Mutation::Upsert {
7933                    table: "tasks".into(),
7934                    values,
7935                    base_version: None,
7936                }]
7937            };
7938            let mut client = open(&path);
7939            client.create_synced_tables().expect("create synced tables");
7940            let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
7941            drop(client);
7942
7943            let connection = Connection::open(&path).expect("raw connection");
7944            // The first operation triggers the drop with canonical cells (a
7945            // stored null for a non-nullable column); the second one is the
7946            // operation shape the journal reader refuses.
7947            let planted = json!([
7948                {
7949                    "op": "upsert",
7950                    "table": "tasks",
7951                    "rowId": "t1",
7952                    "baseVersion": null,
7953                    "values": {"id": "t1", "project_id": null,
7954                        "payload": {"$bytes": "0a"}}
7955                },
7956                second_op
7957            ]);
7958            connection
7959                .execute(
7960                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
7961                    rusqlite::params![planted.to_string(), stale],
7962                )
7963                .expect("plant legacy intent");
7964            drop(connection);
7965
7966            let reopened = open(&path);
7967            let journal = reopened
7968                .commit_outcome(&stale)
7969                .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
7970                .unwrap_or_else(|| panic!("{label}: journal entry"));
7971            assert!(
7972                matches!(journal.status, CommitOutcomeStatus::Rejected),
7973                "{label}"
7974            );
7975            assert!(journal.operations.is_none(), "{label}");
7976            assert!(
7977                reopened
7978                    .commit_outcomes(CommitOutcomeQuery::default())
7979                    .is_ok(),
7980                "{label}: outcome list query"
7981            );
7982            assert!(reopened.pending_commit_ids().is_empty(), "{label}");
7983            drop(reopened);
7984            let _ = std::fs::remove_file(&path);
7985        }
7986    }
7987
7988    #[test]
7989    fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
7990        let schema = |version: i32, extra: bool| {
7991            let mut columns = vec![
7992                json!({"name":"id","type":"string","nullable":false}),
7993                json!({"name":"project_id","type":"string","nullable":false}),
7994                json!({"name":"payload","type":"bytes","nullable":true}),
7995            ];
7996            if extra {
7997                columns.push(json!({"name":"later","type":"string","nullable":true}));
7998            }
7999            json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
8000                "scopes":[{"pattern":"project:{project_id}"}]}]})
8001        };
8002        for (label, bump) in [
8003            ("same-version recreation", false),
8004            ("version-bump reset", true),
8005            ("version-only bump reset", true),
8006        ] {
8007            let path = std::env::temp_dir().join(format!(
8008                "syncular-legacy-reset-{}-{}.db",
8009                label.replace(' ', "-"),
8010                uuid::Uuid::new_v4()
8011            ));
8012            let v1 = schema(1, false);
8013            let open = |schema: &Value, path: &std::path::Path| {
8014                SyncClient::open_path(
8015                    "legacy-reset".into(),
8016                    schema,
8017                    ClientLimits::default(),
8018                    path.to_str().unwrap(),
8019                )
8020                .expect("open client")
8021            };
8022            let row = |id: &str| {
8023                Map::from_iter([
8024                    ("id".to_owned(), json!(id)),
8025                    ("project_id".to_owned(), json!("p1")),
8026                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8027                ])
8028            };
8029            let upsert = |values| {
8030                vec![Mutation::Upsert {
8031                    table: "tasks".into(),
8032                    values,
8033                    base_version: None,
8034                }]
8035            };
8036            let mut client = open(&v1, &path);
8037            client.create_synced_tables().expect("create synced tables");
8038            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8039            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8040            drop(client);
8041
8042            let connection = Connection::open(&path).expect("raw connection");
8043            let planted = json!([{
8044                "op": "upsert",
8045                "table": "tasks",
8046                "rowId": "t1",
8047                "baseVersion": null,
8048                "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
8049            }]);
8050            connection
8051                .execute(
8052                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8053                    rusqlite::params![planted.to_string(), stale],
8054                )
8055                .expect("plant legacy intent");
8056            drop(connection);
8057
8058            let target = if bump {
8059                schema(2, label == "version-bump reset")
8060            } else {
8061                v1.clone()
8062            };
8063            let reopened = open(&target, &path);
8064            let rejection = reopened
8065                .rejections()
8066                .iter()
8067                .find(|rejection| rejection.client_commit_id == stale)
8068                .unwrap_or_else(|| panic!("{label}: rejection"));
8069            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8070            assert_eq!(
8071                rejection
8072                    .details
8073                    .as_ref()
8074                    .and_then(|details| details.reason.as_deref()),
8075                Some(INVALID_STORED_VALUES_REASON),
8076                "{label}"
8077            );
8078            assert!(rejection.operation.is_some(), "{label}");
8079            assert_eq!(
8080                reopened.pending_commit_ids(),
8081                vec![successor.clone()],
8082                "{label}"
8083            );
8084            // The successor survives and still encodes. A bump that adds a
8085            // column leaves the older full-row upsert partial over the empty
8086            // base, so its visible row appears when the server answers it
8087            // (§7.1); the same-version recreation replays it locally.
8088            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8089            if label == "version-bump reset" {
8090                assert_eq!(
8091                    reopened
8092                        .query("SELECT id FROM tasks ORDER BY id", &[])
8093                        .unwrap(),
8094                    Vec::<Map<String, Value>>::new(),
8095                    "{label}"
8096                );
8097                // The guarded bump reset owns the publication, so its change
8098                // batch carries the recovery's rejection, outcome, and table
8099                // domains at the revision it advanced to.
8100                let mut next = reopened;
8101                let revision = next.local_revision();
8102                let batches = next.drain_change_batches();
8103                assert!(
8104                    batches.iter().any(|batch| batch.rejections_changed
8105                        && batch.outcomes_changed
8106                        && batch.tables.iter().any(|change| change.table == "tasks")
8107                        && batch.revision == revision.to_string()),
8108                    "{label}: {batches:?}"
8109                );
8110                next.mutate(upsert(Map::from_iter([
8111                    ("id".to_owned(), json!("t3")),
8112                    ("project_id".to_owned(), json!("p1")),
8113                    ("payload".to_owned(), json!({"$bytes": "0b"})),
8114                    ("later".to_owned(), json!("v2")),
8115                ])))
8116                .expect("v2 authoring after the reset");
8117                assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
8118                assert_eq!(next.pending_payloads().len(), 2, "{label}");
8119                drop(next);
8120            } else {
8121                assert_eq!(
8122                    reopened
8123                        .query("SELECT id FROM tasks ORDER BY id", &[])
8124                        .unwrap(),
8125                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8126                    "{label}"
8127                );
8128                drop(reopened);
8129            }
8130
8131            // Queue and corrupt a new commit while the client stays open so only
8132            // recreation can recover it.
8133            if !bump {
8134                let mut recreated = open(&v1, &path);
8135                let fresh = recreated
8136                    .mutate(upsert(row("t3")))
8137                    .expect("fresh commit while open");
8138                recreated.drain_change_batches();
8139                let planted_fresh = json!([{
8140                    "op": "upsert",
8141                    "table": "tasks",
8142                    "rowId": "t3",
8143                    "baseVersion": null,
8144                    "values": {"id": "t3", "project_id": "p1",
8145                        "payload": {"$bytes": "zz"}}
8146                }]);
8147                let connection = Connection::open(&path).expect("raw connection");
8148                let affected = connection
8149                    .execute(
8150                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8151                        rusqlite::params![planted_fresh.to_string(), fresh],
8152                    )
8153                    .expect("plant fresh legacy intent");
8154                assert_eq!(
8155                    affected, 1,
8156                    "{label}: the in-place plant must hit the queued commit"
8157                );
8158                drop(connection);
8159                let revision_before = recreated.local_revision();
8160                recreated
8161                    .recreate_with_schema(&v1)
8162                    .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
8163                assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
8164                let fresh_rejection = recreated
8165                    .rejections()
8166                    .iter()
8167                    .find(|rejection| rejection.client_commit_id == fresh)
8168                    .expect("the in-place recovery rejection");
8169                assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8170                assert_eq!(
8171                    fresh_rejection
8172                        .details
8173                        .as_ref()
8174                        .and_then(|details| details.reason.as_deref()),
8175                    Some(INVALID_STORED_VALUES_REASON),
8176                    "{label}"
8177                );
8178                assert!(
8179                    matches!(
8180                        recreated
8181                            .commit_outcome(&fresh)
8182                            .expect("outcome read")
8183                            .map(|outcome| outcome.status),
8184                        Some(CommitOutcomeStatus::Rejected)
8185                    ),
8186                    "{label}"
8187                );
8188                assert!(recreated.local_revision() > revision_before, "{label}");
8189                let revision = recreated.local_revision();
8190                let batches = recreated.drain_change_batches();
8191                assert_eq!(batches.len(), 1, "{label}: one recovery batch");
8192                assert!(
8193                    batches.iter().any(|batch| batch.rejections_changed
8194                        && batch.outcomes_changed
8195                        && batch.tables.iter().any(|change| change.table == "tasks")
8196                        && batch.revision == revision.to_string()),
8197                    "{label}: {batches:?}"
8198                );
8199                assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
8200                assert_eq!(
8201                    recreated
8202                        .query("SELECT id FROM tasks ORDER BY id", &[])
8203                        .unwrap(),
8204                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8205                    "{label}: the recovered commit leaves no projection"
8206                );
8207                drop(recreated);
8208            }
8209            let _ = std::fs::remove_file(&path);
8210        }
8211    }
8212
8213    #[test]
8214    fn legacy_values_recover_over_a_synced_authoritative_row() {
8215        let path = std::env::temp_dir().join(format!(
8216            "syncular-authoritative-row-{}.db",
8217            uuid::Uuid::new_v4()
8218        ));
8219        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8220            {"name":"id","type":"string","nullable":false},
8221            {"name":"project_id","type":"string","nullable":false},
8222            {"name":"title","type":"string","nullable":false},
8223            {"name":"payload","type":"bytes","nullable":true}],
8224            "scopes":[{"pattern":"project:{project_id}"}]}]});
8225        let open = |path: &std::path::Path| {
8226            SyncClient::open_path(
8227                "authoritative-row".into(),
8228                &schema,
8229                ClientLimits::default(),
8230                path.to_str().unwrap(),
8231            )
8232            .expect("open client")
8233        };
8234        let row = |title: &str| {
8235            Map::from_iter([
8236                ("id".to_owned(), json!("t1")),
8237                ("project_id".to_owned(), json!("p1")),
8238                ("title".to_owned(), json!(title)),
8239                ("payload".to_owned(), json!({"$bytes": "0a"})),
8240            ])
8241        };
8242        let upsert = |values| {
8243            vec![Mutation::Upsert {
8244                table: "tasks".into(),
8245                values,
8246                base_version: None,
8247            }]
8248        };
8249        let mut client = open(&path);
8250        client.create_synced_tables().expect("create synced tables");
8251        // A server-confirmed row: base and visible carry the authoritative
8252        // values at version 1.
8253        client
8254            .conn
8255            .execute_batch(
8256                "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
8257                 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
8258            )
8259            .expect("seed authoritative row");
8260        let older = client.mutate(upsert(row("older"))).expect("older");
8261        let newer = client.mutate(upsert(row("newer"))).expect("newer");
8262        let successor = client.mutate(upsert(row("successor"))).expect("successor");
8263        drop(client);
8264
8265        let connection = Connection::open(&path).expect("raw connection");
8266        for commit_id in [&older, &newer] {
8267            let planted = json!([{
8268                "op": "upsert",
8269                "table": "tasks",
8270                "rowId": "t1",
8271                "baseVersion": null,
8272                "values": {"id": "t1", "project_id": "p1", "title": "legacy",
8273                    "payload": {"$bytes": "zz"}}
8274            }]);
8275            connection
8276                .execute(
8277                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8278                    rusqlite::params![planted.to_string(), commit_id],
8279                )
8280                .expect("plant legacy intent");
8281        }
8282        drop(connection);
8283
8284        let reopened = open(&path);
8285        assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8286        assert_eq!(
8287            reopened
8288                .rejections()
8289                .iter()
8290                .map(|rejection| rejection.client_commit_id.clone())
8291                .collect::<Vec<_>>(),
8292            vec![older.clone(), newer.clone()]
8293        );
8294        // The pending successor overlays the restored authoritative row, which
8295        // keeps its server version.
8296        assert_eq!(
8297            reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
8298            Ok(vec![Map::from_iter([
8299                ("id".to_owned(), json!("t1")),
8300                ("title".to_owned(), json!("successor")),
8301                ("_syncular_version".to_owned(), json!(1)),
8302            ])])
8303        );
8304
8305        // A later rejection of the surviving successor restores the
8306        // authoritative values and version exactly.
8307        drop(reopened);
8308        let connection = Connection::open(&path).expect("raw connection");
8309        connection
8310            .execute(
8311                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8312                rusqlite::params![
8313                    json!([{
8314                        "op": "upsert",
8315                        "table": "tasks",
8316                        "rowId": "t1",
8317                        "baseVersion": null,
8318                        "values": {"id": "t1", "project_id": "p1", "title": "legacy",
8319                            "payload": {"$bytes": "zz"}}
8320                    }])
8321                    .to_string(),
8322                    successor
8323                ],
8324            )
8325            .expect("plant successor intent");
8326        drop(connection);
8327        let late = open(&path);
8328        assert!(late.pending_commit_ids().is_empty());
8329        assert_eq!(
8330            late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
8331            Ok(vec![Map::from_iter([
8332                ("id".to_owned(), json!("t1")),
8333                ("title".to_owned(), json!("authoritative")),
8334                ("_syncular_version".to_owned(), json!(1)),
8335            ])])
8336        );
8337        drop(late);
8338        let _ = std::fs::remove_file(&path);
8339    }
8340
8341    #[test]
8342    fn a_failed_value_recovery_commit_restores_every_side_effect() {
8343        let path = std::env::temp_dir().join(format!(
8344            "syncular-value-recovery-commit-{}.db",
8345            uuid::Uuid::new_v4()
8346        ));
8347        let path_str = path.to_str().expect("path");
8348        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8349            {"name":"id","type":"string","nullable":false},
8350            {"name":"project_id","type":"string","nullable":false},
8351            {"name":"payload","type":"bytes","nullable":true}],
8352            "scopes":[{"pattern":"project:{project_id}"}]}]});
8353        // A rollback-journal replica, so a second read connection can hold
8354        // SHARED across the recovery transaction's COMMIT.
8355        let conn = Connection::open(path_str).expect("replica connection");
8356        let mut client = SyncClient::with_connection(
8357            "value-recovery-commit".into(),
8358            &schema,
8359            ClientLimits::default(),
8360            conn,
8361        )
8362        .expect("replica");
8363        assert_eq!(
8364            client
8365                .conn
8366                .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
8367                .expect("journal mode"),
8368            "delete"
8369        );
8370        client.create_synced_tables().expect("create synced tables");
8371        let stale = client
8372            .mutate(vec![Mutation::Upsert {
8373                table: "tasks".into(),
8374                values: Map::from_iter([
8375                    ("id".to_owned(), json!("t1")),
8376                    ("project_id".to_owned(), json!("p1")),
8377                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8378                ]),
8379                base_version: None,
8380            }])
8381            .expect("queued write");
8382        client.drain_change_batches();
8383        let affected = client
8384            .conn
8385            .execute(
8386                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8387                rusqlite::params![
8388                    json!([{
8389                        "op": "upsert",
8390                        "table": "tasks",
8391                        "rowId": "t1",
8392                        "baseVersion": null,
8393                        "values": {"id": "t1", "project_id": "p1",
8394                            "payload": {"$bytes": "zz"}}
8395                    }])
8396                    .to_string(),
8397                    stale
8398                ],
8399            )
8400            .expect("plant legacy intent");
8401        assert_eq!(affected, 1, "the planted row must exist");
8402        let revision = client.local_revision();
8403        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
8404        let prior_outbox = client.pending_commit_ids();
8405        let prior_rejections = client.rejections().len();
8406        let prior_changes = client.change_queue.len();
8407        let prior_intents = client.sync_intent_queue.len();
8408        let prior_failed = client.failed_commits.len();
8409        // The reader holds SHARED for the whole attempt: the recovery runs to
8410        // its publication and the COMMIT fails.
8411        let reader = Connection::open(path_str).expect("reader");
8412        reader
8413            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
8414            .expect("reader snapshot");
8415        client
8416            .conn
8417            .busy_timeout(std::time::Duration::ZERO)
8418            .expect("busy timeout");
8419        let error = client
8420            .recreate_with_schema(&schema)
8421            .expect_err("a blocked COMMIT must refuse the recovery");
8422        assert!(error.starts_with("client.storage_busy:"), "{error}");
8423        assert!(client.conn.is_autocommit(), "the transaction is released");
8424        assert_eq!(client.local_revision(), revision);
8425        assert_eq!(client.pending_commit_ids(), prior_outbox);
8426        assert_eq!(client.rejections().len(), prior_rejections);
8427        assert_eq!(client.change_queue.len(), prior_changes);
8428        assert_eq!(client.sync_intent_queue.len(), prior_intents);
8429        assert_eq!(client.failed_commits.len(), prior_failed);
8430        assert_eq!(
8431            client.query("SELECT * FROM tasks", &[]).expect("rows"),
8432            rows
8433        );
8434        assert!(
8435            client
8436                .commit_outcome(&stale)
8437                .expect("outcome read")
8438                .is_none(),
8439            "the rolled-back recovery kept no outcome"
8440        );
8441        // The retry runs the same publication branch to completion.
8442        reader.execute_batch("ROLLBACK").expect("release reader");
8443        client
8444            .recreate_with_schema(&schema)
8445            .expect("retry after the blocked commit");
8446        assert!(client.pending_commit_ids().is_empty());
8447        assert_eq!(client.rejections().len(), prior_rejections + 1);
8448        assert!(client.local_revision() > revision);
8449        let batches = client.drain_change_batches();
8450        assert!(
8451            batches.iter().any(|batch| batch.rejections_changed
8452                && batch.outcomes_changed
8453                && batch.tables.iter().any(|change| change.table == "tasks")),
8454            "{batches:?}"
8455        );
8456        drop(client);
8457        let _ = std::fs::remove_file(&path);
8458    }
8459
8460    #[cfg(feature = "e2ee")]
8461    #[test]
8462    fn a_failed_recovery_restores_retained_failed_commits() {
8463        let path = std::env::temp_dir().join(format!(
8464            "syncular-value-recovery-retention-{}.db",
8465            uuid::Uuid::new_v4()
8466        ));
8467        let path_str = path.to_str().expect("path");
8468        let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
8469            {"name":"id","type":"string","nullable":false},
8470            {"name":"project_id","type":"string","nullable":false},
8471            {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
8472            "scopes":[{"pattern":"project:{project_id}"}]}]});
8473        let conn = Connection::open(path_str).expect("replica connection");
8474        let mut client = SyncClient::with_connection(
8475            "value-recovery-retention".into(),
8476            &schema,
8477            ClientLimits::default(),
8478            conn,
8479        )
8480        .expect("replica");
8481        client.create_synced_tables().expect("create synced tables");
8482        client.set_retain_failed_commits(true).expect("retention");
8483        // No keys configured: the §5.11 seal fails, so retention is exercised.
8484        client.set_encryption(crate::values::EncryptionConfig::default());
8485        client
8486            .mutate(vec![Mutation::Upsert {
8487                table: "secrets".into(),
8488                values: Map::from_iter([
8489                    ("id".to_owned(), json!("s1")),
8490                    ("project_id".to_owned(), json!("p1")),
8491                    ("note".to_owned(), json!("secret")),
8492                ]),
8493                base_version: None,
8494            }])
8495            .expect("queued write");
8496        client.drain_change_batches();
8497        let revision = client.local_revision();
8498        let prior_outbox = client.pending_commit_ids();
8499        let prior_rejections = client.rejections().len();
8500        let prior_failed = client.failed_commits.len();
8501        let reader = Connection::open(path_str).expect("reader");
8502        reader
8503            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
8504            .expect("reader snapshot");
8505        client
8506            .conn
8507            .busy_timeout(std::time::Duration::ZERO)
8508            .expect("busy timeout");
8509        let error = client
8510            .drop_unencodable_outbox(true, None)
8511            .expect_err("a blocked COMMIT must refuse the recovery");
8512        assert!(error.starts_with("client.storage_busy:"), "{error}");
8513        assert_eq!(client.failed_commits.len(), prior_failed);
8514        assert_eq!(client.pending_commit_ids(), prior_outbox);
8515        assert_eq!(client.rejections().len(), prior_rejections);
8516        assert_eq!(client.local_revision(), revision);
8517        // The retry retains the failed commit and drops it from the outbox.
8518        reader.execute_batch("ROLLBACK").expect("release reader");
8519        client
8520            .drop_unencodable_outbox(true, None)
8521            .expect("retry after the blocked commit");
8522        assert_eq!(client.failed_commits.len(), prior_failed + 1);
8523        assert!(client.pending_commit_ids().is_empty());
8524        assert_eq!(client.rejections().len(), prior_rejections + 1);
8525        drop(client);
8526        let _ = std::fs::remove_file(&path);
8527    }
8528
8529    #[test]
8530    fn client_error_maps_legacy_codes_and_static_messages() {
8531        let code_only = ClientError::from("sync.unknown_table".to_owned());
8532        assert_eq!(code_only.code, "sync.unknown_table");
8533        assert_eq!(code_only.message, "the commit targets an unknown table");
8534        assert!(code_only.details.is_none());
8535
8536        let dynamic = ClientError::from(
8537            "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
8538        );
8539        assert_eq!(dynamic.code, "sync.invalid_request");
8540        assert_eq!(dynamic.message, "the authoring request is invalid");
8541        assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
8542            .as_str()
8543            .unwrap()
8544            .contains("patch cannot write scope column"));
8545
8546        let unclassified =
8547            ClientError::from("a commit must contain at least one operation".to_owned());
8548        assert_eq!(unclassified.code, "client.failed");
8549        assert_eq!(unclassified.message, "the local authoring operation failed");
8550        assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
8551            .as_str()
8552            .unwrap()
8553            .contains("at least one operation"));
8554
8555        // An unclassified QueryReadFailure keeps its numeric SQLite evidence.
8556        let metadata = ClientError::from(QueryReadFailure {
8557            code: None,
8558            sqlite_code: Some(1),
8559            sqlite_message: Some("generic SQLite prose".to_owned()),
8560            rollback_failure: Some(Box::new(QueryReadFailure::from(
8561                rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
8562            ))),
8563            message: "unclassified read".to_owned(),
8564        });
8565        assert_eq!(metadata.code, "client.failed");
8566        assert_eq!(metadata.message, "the local authoring operation failed");
8567        let details = metadata.details.as_ref().unwrap();
8568        assert_eq!(details["sqliteCode"], 1);
8569        assert_eq!(details["sqliteMessage"], "generic SQLite prose");
8570        assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
8571        assert_eq!(details["legacyCause"], "unclassified read");
8572    }
8573
8574    #[test]
8575    fn schema_marker_read_keeps_storage_classification() {
8576        let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
8577            rusqlite::ffi::Error::new(5),
8578            Some("database is locked".to_owned()),
8579        ));
8580        assert!(busy.starts_with("client.storage_busy:"), "{busy}");
8581        let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
8582            rusqlite::ffi::Error::new(1),
8583            Some("unreadable".to_owned()),
8584        ));
8585        assert_eq!(
8586            invalid,
8587            "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
8588        );
8589
8590        // A real exclusive lock on the marker read surfaces BUSY, not corrupt,
8591        // and the guard writes nothing.
8592        let path =
8593            std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
8594        let setup = Connection::open(&path).expect("setup connection");
8595        setup
8596            .execute_batch(
8597                "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n                 INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
8598            )
8599            .unwrap();
8600        let locker = Connection::open(&path).expect("locker connection");
8601        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
8602        let reader = Connection::open(&path).expect("reader connection");
8603        reader.busy_timeout(std::time::Duration::ZERO).unwrap();
8604        let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
8605        assert!(locked.starts_with("client.storage_busy:"), "{locked}");
8606        locker.execute_batch("ROLLBACK").unwrap();
8607        assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
8608        let rows = setup
8609            .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
8610                row.get::<_, i64>(0)
8611            })
8612            .unwrap();
8613        assert_eq!(rows, 1, "the guard performed no writes");
8614        drop(reader);
8615        drop(locker);
8616        drop(setup);
8617        let _ = std::fs::remove_file(&path);
8618    }
8619
8620    #[cfg(feature = "crdt-yjs")]
8621    #[test]
8622    fn crdt_authoring_pre_read_preserves_storage_classification() {
8623        let path =
8624            std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
8625        let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
8626            {"name":"id","type":"string","nullable":false},
8627            {"name":"project_id","type":"string","nullable":false},
8628            {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
8629            "scopes":[{"pattern":"project:{project_id}"}]}]});
8630        let mut client = SyncClient::open_path(
8631            "crdt-busy".into(),
8632            &schema,
8633            ClientLimits::default(),
8634            path.to_str().unwrap(),
8635        )
8636        .expect("open client");
8637        client.create_synced_tables().expect("create synced tables");
8638        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8639
8640        let locker = Connection::open(&path).expect("locker connection");
8641        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
8642        let busy = client
8643            .crdt_apply_update("notes", "n1", "doc", &[])
8644            .expect_err("busy crdt pre-read");
8645        assert_eq!(busy.code, "client.storage_busy");
8646        assert!(busy.retryable);
8647        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8648        assert!(client.pending_commit_ids().is_empty());
8649        assert_eq!(client.local_revision(), 0);
8650
8651        locker.execute_batch("ROLLBACK").unwrap();
8652        drop(locker);
8653        drop(client);
8654        let _ = std::fs::remove_file(&path);
8655    }
8656
8657    #[test]
8658    fn large_unique_import_copies_no_unrelated_tables() {
8659        let (mut client, image, table) = unique_import_fixture(10240);
8660        let pending = client.pending_commit_ids();
8661        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
8662            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
8663        assert!(matches!(
8664            client.apply_sqlite_image(
8665                &image,
8666                &table,
8667                true,
8668                &[("project_id".into(), vec!["p1".into()])],
8669                10240,
8670                7,
8671                "digest"
8672            ),
8673            Ok(10240)
8674        ));
8675        assert_eq!(client.overlay_rebuild_count.get(), 0);
8676        assert_eq!(client.pending_commit_ids(), pending);
8677        assert_eq!(
8678            client
8679                .query("SELECT count(*) AS n FROM tasks", &[])
8680                .unwrap()[0]["n"],
8681            10241
8682        );
8683        assert_eq!(
8684            client
8685                .query("SELECT count(*) AS n FROM tasks_fts", &[])
8686                .unwrap()[0]["n"],
8687            10241
8688        );
8689        assert_eq!(
8690            client
8691                .query("SELECT count(*) AS n FROM notes_fts", &[])
8692                .unwrap()[0]["n"],
8693            8192
8694        );
8695    }
8696
8697    #[test]
8698    #[ignore = "manual scaling benchmark; see bench/README.md"]
8699    fn benchmark_unique_image_import() {
8700        let mut samples = Vec::new();
8701        for rows in [10240, 51200, 102400] {
8702            for trial in 0..4 {
8703                let (mut client, image, table) = unique_import_fixture(rows);
8704                let start = std::time::Instant::now();
8705                assert!(
8706                    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)
8707                );
8708                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
8709                assert_eq!(
8710                    client
8711                        .query("SELECT count(*) AS n FROM tasks", &[])
8712                        .unwrap()[0]["n"],
8713                    rows + 1
8714                );
8715                assert_eq!(
8716                    client
8717                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
8718                        .unwrap()[0]["n"],
8719                    rows + 1
8720                );
8721                assert_eq!(
8722                    client
8723                        .query("SELECT count(*) AS n FROM notes_fts", &[])
8724                        .unwrap()[0]["n"],
8725                    8192
8726                );
8727                assert_eq!(client.pending_commit_ids().len(), 1);
8728                if trial > 0 {
8729                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
8730                }
8731            }
8732        }
8733        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});
8734        let output = serde_json::to_string_pretty(&artifact).unwrap();
8735        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
8736            use std::io::Write;
8737            let mut file = std::fs::OpenOptions::new()
8738                .write(true)
8739                .create_new(true)
8740                .open(path)
8741                .unwrap();
8742            file.write_all(output.as_bytes()).unwrap();
8743        } else {
8744            println!("{output}");
8745        }
8746    }
8747
8748    #[test]
8749    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
8750        let schema = json!({
8751            "version": 1,
8752            "tables": [{
8753                "name": "catalogue_codes",
8754                "primaryKey": "id",
8755                "columns": [
8756                    { "name": "id", "type": "string", "nullable": false },
8757                    { "name": "release_id", "type": "string", "nullable": false },
8758                    { "name": "code", "type": "string", "nullable": false },
8759                    { "name": "title", "type": "string", "nullable": false }
8760                ],
8761                "scopes": [{ "pattern": "release:{release_id}" }],
8762                "ftsIndexes": [{
8763                    "name": "catalogue_codes_fts",
8764                    "columns": ["code", "title"],
8765                    "tokenize": "unicode61 remove_diacritics 2"
8766                }]
8767            }]
8768        });
8769        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
8770            .expect("FTS5 client");
8771        let insert_trigger: String = client
8772            .conn
8773            .query_row(
8774                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
8775                [],
8776                |row| row.get(0),
8777            )
8778            .expect("insert trigger");
8779        let replace_guard: String = client
8780            .conn
8781            .query_row(
8782                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
8783                [],
8784                |row| row.get(0),
8785            )
8786            .expect("replace guard");
8787        assert!(!insert_trigger.contains("DELETE FROM"));
8788        assert!(replace_guard.contains("BEFORE INSERT"));
8789        assert!(replace_guard.contains("WHEN EXISTS"));
8790        let search = |client: &SyncClient, query: &str| {
8791            client
8792                .query(
8793                    "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",
8794                    &[Value::from(query)],
8795                )
8796                .expect("FTS query")
8797        };
8798
8799        client
8800            .mutate(vec![Mutation::Upsert {
8801                table: "catalogue_codes".to_owned(),
8802                values: Map::from_iter([
8803                    ("id".to_owned(), Value::from("c1")),
8804                    ("release_id".to_owned(), Value::from("r1")),
8805                    ("code".to_owned(), Value::from("A01")),
8806                    ("title".to_owned(), Value::from("Cholera")),
8807                ]),
8808                base_version: None,
8809            }])
8810            .expect("insert code");
8811        assert_eq!(search(&client, "cholera").len(), 1);
8812        let prior = client
8813            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
8814            .unwrap();
8815        client
8816            .conn
8817            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
8818            .unwrap();
8819        client.create_synced_tables().unwrap();
8820        assert_eq!(
8821            client
8822                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
8823                .unwrap(),
8824            prior
8825        );
8826        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();
8827        let rendered = serde_json::to_string(&plan).unwrap();
8828        assert!(rendered.contains("COVERING INDEX"));
8829        assert!(rendered.contains("INDEX 0:="));
8830        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
8831        assert_eq!(search(&client, "cholera").len(), 1);
8832
8833        client
8834            .mutate(vec![Mutation::Upsert {
8835                table: "catalogue_codes".to_owned(),
8836                values: Map::from_iter([
8837                    ("id".to_owned(), Value::from("c1")),
8838                    ("release_id".to_owned(), Value::from("r1")),
8839                    ("code".to_owned(), Value::from("A01")),
8840                    ("title".to_owned(), Value::from("Enteric infection")),
8841                ]),
8842                base_version: None,
8843            }])
8844            .expect("update code");
8845        assert!(search(&client, "cholera").is_empty());
8846        assert_eq!(search(&client, "enteric").len(), 1);
8847
8848        client
8849            .mutate(vec![Mutation::Delete {
8850                table: "catalogue_codes".to_owned(),
8851                row_id: "c1".to_owned(),
8852                base_version: None,
8853            }])
8854            .expect("delete code");
8855        assert!(search(&client, "enteric").is_empty());
8856    }
8857
8858    #[test]
8859    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
8860        let schema = json!({
8861            "version": 1,
8862            "tables": [{
8863                "name": "patient_notes",
8864                "primaryKey": "id",
8865                "columns": [
8866                    { "name": "id", "type": "string", "nullable": false },
8867                    { "name": "practice_id", "type": "string", "nullable": false },
8868                    { "name": "encryption_key_id", "type": "string", "nullable": false },
8869                    { "name": "title", "type": "string", "nullable": false }
8870                ],
8871                "scopes": [{ "pattern": "practice:{practice_id}" }],
8872                "ftsIndexes": [{
8873                    "name": "patient_notes_fts",
8874                    "columns": ["title"],
8875                    "tokenize": "unicode61 remove_diacritics 2"
8876                }]
8877            }]
8878        });
8879        let mut client = SyncClient::new(
8880            "local-purge-test".to_owned(),
8881            &schema,
8882            ClientLimits::default(),
8883        )
8884        .expect("local purge client");
8885        client
8886            .conn
8887            .execute(
8888                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
8889                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
8890                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
8891                [],
8892            )
8893            .expect("seed base rows");
8894        client.overlay_dirty.set(true);
8895        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8896
8897        let note = |id: &str, key_id: &str, title: &str| {
8898            Map::from_iter([
8899                ("id".to_owned(), Value::from(id)),
8900                ("practice_id".to_owned(), Value::from("practice-1")),
8901                ("encryption_key_id".to_owned(), Value::from(key_id)),
8902                ("title".to_owned(), Value::from(title)),
8903            ])
8904        };
8905        let doomed = client
8906            .mutate(vec![
8907                Mutation::Upsert {
8908                    table: "patient_notes".to_owned(),
8909                    values: note("target", "key-revoked", "Target changed"),
8910                    base_version: None,
8911                },
8912                Mutation::Upsert {
8913                    table: "patient_notes".to_owned(),
8914                    values: note("unrelated", "key-held", "Unrelated changed"),
8915                    base_version: None,
8916                },
8917            ])
8918            .expect("doomed commit");
8919        let kept = client
8920            .mutate(vec![Mutation::Upsert {
8921                table: "patient_notes".to_owned(),
8922                values: note("kept", "key-held", "Kept optimistic"),
8923                base_version: None,
8924            }])
8925            .expect("kept commit");
8926        client.drain_change_batches();
8927
8928        let input = LocalDataPurgeInput {
8929            purge_id: "purge-001".to_owned(),
8930            targets: vec![LocalDataPurgeTarget {
8931                table: "patient_notes".to_owned(),
8932                selectors: BTreeMap::from([(
8933                    "encryption_key_id".to_owned(),
8934                    vec!["key-revoked".to_owned()],
8935                )]),
8936            }],
8937        };
8938        assert_eq!(
8939            client.purge_local_data(&input).expect("apply purge"),
8940            LocalDataPurgeResult {
8941                already_applied: false,
8942                purged_rows: 1,
8943                dropped_commits: 1,
8944            }
8945        );
8946        let rows = client
8947            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
8948            .expect("visible rows");
8949        assert_eq!(rows.len(), 2);
8950        assert_eq!(rows[0]["id"], "kept");
8951        assert_eq!(rows[1]["id"], "unrelated");
8952        assert_eq!(rows[1]["title"], "Unrelated original");
8953        let fts = client
8954            .query(
8955                "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'",
8956                &[],
8957            )
8958            .expect("fts query");
8959        assert!(fts.is_empty());
8960        assert_eq!(client.outbox.len(), 1);
8961        assert_eq!(client.outbox[0].client_commit_id, kept);
8962        let outcome = client
8963            .commit_outcome(&doomed)
8964            .expect("read doomed outcome")
8965            .expect("doomed outcome");
8966        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
8967        match &outcome.results[0] {
8968            CommitOperationOutcome::Error { rejection } => {
8969                assert_eq!(rejection.code, "client.local_data_purged");
8970                assert_eq!(rejection.client_commit_id, doomed);
8971            }
8972            other => panic!("expected local purge rejection, got {other:?}"),
8973        }
8974        assert_eq!(client.drain_change_batches().len(), 1);
8975        assert_eq!(
8976            client.purge_local_data(&input).expect("retry purge"),
8977            LocalDataPurgeResult {
8978                already_applied: true,
8979                purged_rows: 0,
8980                dropped_commits: 0,
8981            }
8982        );
8983        let conflicting = LocalDataPurgeInput {
8984            purge_id: input.purge_id.clone(),
8985            targets: vec![LocalDataPurgeTarget {
8986                table: "patient_notes".to_owned(),
8987                selectors: BTreeMap::from([(
8988                    "encryption_key_id".to_owned(),
8989                    vec!["key-held".to_owned()],
8990                )]),
8991            }],
8992        };
8993        assert!(client
8994            .purge_local_data(&conflicting)
8995            .expect_err("id collision must fail")
8996            .contains("already used with a different plan"));
8997    }
8998}
8999
9000impl SubState {
9001    fn name(self) -> &'static str {
9002        match self {
9003            SubState::Active => "active",
9004            SubState::Revoked => "revoked",
9005            SubState::Failed => "failed",
9006        }
9007    }
9008
9009    fn parse(value: &str) -> Self {
9010        match value {
9011            "revoked" => Self::Revoked,
9012            "failed" => Self::Failed,
9013            _ => Self::Active,
9014        }
9015    }
9016}
9017
9018#[derive(Debug, Clone)]
9019struct Subscription {
9020    id: String,
9021    table: String,
9022    requested: Vec<(String, Vec<String>)>,
9023    params: Option<String>,
9024    cursor: i64,
9025    /// §4.7 resume token, round-tripped opaquely.
9026    bootstrap_state: Option<String>,
9027    state: SubState,
9028    reason_code: Option<String>,
9029    /// Last effective scopes echoed while active (§3.3: persisted for the
9030    /// purge contract; each active echo replaces it).
9031    effective: Option<Vec<(String, Vec<String>)>>,
9032    synced_once: bool,
9033}
9034
9035#[derive(Debug, Clone)]
9036struct OutboxOp {
9037    upsert: bool,
9038    table: String,
9039    row_id: String,
9040    base_version: Option<i64>,
9041    /// Schema-agnostic local form (§0): driver JSON values, encoded with
9042    /// the current codec at send time. A key's presence IS the §6.1 sparse
9043    /// presence set — both the overlay and the wire payload derive from it.
9044    values: Option<Map<String, Value>>,
9045}
9046
9047impl From<&OutboxOp> for CommitOperation {
9048    fn from(operation: &OutboxOp) -> Self {
9049        Self {
9050            table: operation.table.clone(),
9051            row_id: operation.row_id.clone(),
9052            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
9053            base_version: operation.base_version,
9054            values: operation.values.clone(),
9055        }
9056    }
9057}
9058
9059#[derive(Debug, Clone)]
9060struct OutboxCommit {
9061    client_commit_id: String,
9062    ops: Vec<OutboxOp>,
9063}
9064
9065/// One commit's `(table, value keys)` per upsert operation — the classification
9066/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
9067/// can never disagree about which commits cannot re-encode.
9068fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
9069    commit
9070        .ops
9071        .iter()
9072        .filter(|operation| operation.upsert)
9073        .map(|operation| {
9074            (
9075                operation.table.as_str(),
9076                operation
9077                    .values
9078                    .as_ref()
9079                    .map(|values| values.keys().map(String::as_str).collect())
9080                    .unwrap_or_default(),
9081            )
9082        })
9083        .collect()
9084}
9085
9086type FailedCommit = (OutboxCommit, Map<String, Value>);
9087
9088#[derive(Debug, Clone)]
9089struct CompiledLocalDataPurgeTarget {
9090    table: String,
9091    selectors: Vec<(String, Vec<String>)>,
9092}
9093
9094struct StoredCommitOutcomeRow {
9095    sequence: i64,
9096    client_commit_id: String,
9097    status: String,
9098    recorded_at_ms: i64,
9099    results_json: String,
9100    operations_json: Option<String>,
9101    resolution: String,
9102    resolved_at_ms: Option<i64>,
9103    replacement_client_commit_id: Option<String>,
9104}
9105
9106impl StoredCommitOutcomeRow {
9107    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
9108        Ok(Self {
9109            sequence: row.get(0)?,
9110            client_commit_id: row.get(1)?,
9111            status: row.get(2)?,
9112            recorded_at_ms: row.get(3)?,
9113            results_json: row.get(4)?,
9114            operations_json: row.get(5)?,
9115            resolution: row.get(6)?,
9116            resolved_at_ms: row.get(7)?,
9117            replacement_client_commit_id: row.get(8)?,
9118        })
9119    }
9120}
9121
9122/// Section outcome distinguishing the §5.6 subscription-local fail-closed
9123/// path from a round-aborting failure (§1.4 rule 5).
9124enum SectionError {
9125    FailClosed,
9126    Abort(String, String),
9127}
9128
9129#[derive(Debug)]
9130pub(crate) struct RequestMeta {
9131    pushed_ids: Vec<String>,
9132    /// Subscription id → the request carried `cursor < 0` and no resume
9133    /// token (§5.6 first-page detection: a *fresh* bootstrap).
9134    fresh: Vec<(String, bool)>,
9135    pub(crate) accept: u8,
9136    /// §6.1 splitBatch: outbox commits held back from THIS request because
9137    /// the running operation count reached the push cap — the next round
9138    /// pushes them (`sync_needed` stays set while any remain).
9139    deferred_commits: usize,
9140}
9141
9142#[derive(Default)]
9143struct ChangeAccumulator {
9144    /// None means table-wide; Some is the exact scoped domain.
9145    tables: BTreeMap<String, Option<BTreeSet<String>>>,
9146    windows: BTreeMap<(String, String), BTreeSet<String>>,
9147    status: bool,
9148    conflicts: bool,
9149    rejections: bool,
9150    outcomes: bool,
9151}
9152
9153impl ChangeAccumulator {
9154    fn table(&mut self, table: &str) {
9155        self.tables.insert(table.to_owned(), None);
9156    }
9157
9158    fn scope(&mut self, table: &str, key: String) {
9159        match self.tables.get_mut(table) {
9160            Some(None) => {}
9161            Some(Some(keys)) => {
9162                keys.insert(key);
9163            }
9164            None => {
9165                self.tables
9166                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
9167            }
9168        }
9169    }
9170
9171    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
9172        self.windows
9173            .entry((base_key.to_owned(), table.to_owned()))
9174            .or_default()
9175            .insert(unit.to_owned());
9176    }
9177
9178    fn touched(&self) -> bool {
9179        !self.tables.is_empty()
9180            || !self.windows.is_empty()
9181            || self.status
9182            || self.conflicts
9183            || self.rejections
9184            || self.outcomes
9185    }
9186}
9187
9188/// §6.1: the server caps total operations per request (reference default
9189/// 500) and rejects the whole batch with `sync.too_many_operations`; the
9190/// client "splits and retries". Splitting happens at build time: commits are
9191/// included IN ORDER until the operation budget is spent, the rest wait for
9192/// the next round.
9193const PUSH_OPS_PER_REQUEST: usize = 500;
9194const MAX_LOCAL_PURGE_TARGETS: usize = 64;
9195const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
9196const MAX_LOCAL_PURGE_VALUES: usize = 128;
9197const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
9198pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
9199
9200/// Dirty visible tables; `None` means a whole-replica recovery/reset.
9201struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
9202
9203impl OverlayDirty {
9204    fn clean() -> Self {
9205        Self(RefCell::new(Some(BTreeSet::new())))
9206    }
9207    fn get(&self) -> bool {
9208        self.0
9209            .borrow()
9210            .as_ref()
9211            .is_none_or(|tables| !tables.is_empty())
9212    }
9213    fn set(&self, dirty: bool) {
9214        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
9215    }
9216    fn table(&self, table: &str) {
9217        if let Some(tables) = self.0.borrow_mut().as_mut() {
9218            tables.insert(table.to_owned());
9219        }
9220    }
9221    fn snapshot(&self) -> Option<BTreeSet<String>> {
9222        self.0.borrow().clone()
9223    }
9224    fn restore(&self, tables: Option<BTreeSet<String>>) {
9225        *self.0.borrow_mut() = tables;
9226    }
9227}
9228
9229pub struct SyncClient {
9230    #[cfg(feature = "bench-internals")]
9231    benchmark_phases: Recorder,
9232    conn: Connection,
9233    schema: ClientSchema,
9234    client_id: String,
9235    limits: ClientLimits,
9236    subs: Vec<Subscription>,
9237    outbox: Vec<OutboxCommit>,
9238    failed_commits: Vec<FailedCommit>,
9239    retain_failed_commits: bool,
9240    conflicts: Vec<ConflictRecord>,
9241    rejections: Vec<RejectionRecord>,
9242    schema_floor: Option<SchemaFloor>,
9243    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
9244    lease_state: Option<LeaseState>,
9245    /// §1.6: the schema-floor response stops syncing until an upgrade.
9246    stopped: bool,
9247    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
9248    upgrading: bool,
9249    /// §8.4 coalesced sync-needed signal.
9250    sync_needed: bool,
9251    /// §8.8: how sync rounds treat the realtime binding, and its exact
9252    /// availability state with the evidence behind a lost or refused one.
9253    realtime_policy: RealtimePolicy,
9254    realtime_state: RealtimeState,
9255    realtime_reason_code: Option<String>,
9256    realtime_retry_delay_ms: Option<u64>,
9257    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
9258    presence: HashMap<String, HashMap<String, PresencePeer>>,
9259    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
9260    /// host may pin it (conformance runs on a virtual clock).
9261    now_ms: Option<i64>,
9262    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
9263    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
9264    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
9265    encryption: crate::values::EncryptionConfig,
9266    /// Fail-closed host bootstrap gate. While set, command hosts permit only
9267    /// status/lifecycle inspection and an exact authorized local purge.
9268    security_preflight: bool,
9269    transport_enabled: bool,
9270    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
9271    /// means the feature is off; the descriptor write and the container orphan
9272    /// sweep still happen.
9273    previous_version: Option<PreviousVersionContextConfig>,
9274    /// Per-table primary-key upsert SQL, built once per (full table name) —
9275    /// the row write path runs per row during bootstrap (§5.6), so the SQL
9276    /// string (and, via `prepare_cached`, its compiled statement) is reused
9277    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
9278    /// schema reset (the column lists may have changed).
9279    insert_sql: RefCell<HashMap<String, String>>,
9280    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
9281    /// reset and restart explicitly invalidate every table.
9282    overlay_dirty: OverlayDirty,
9283    // First SQLite failure of the current operation, before savepoint cleanup.
9284    storage_failure: RefCell<Option<QueryReadFailure>>,
9285    /// Test-only structural performance signal: response processing must not
9286    /// turn a batch of acknowledgements into one full overlay rebuild each.
9287    #[cfg(test)]
9288    overlay_rebuild_count: Cell<usize>,
9289    #[cfg(test)]
9290    acknowledged_replay_count: Cell<usize>,
9291    /// Test-only structural performance signal: outcome retention is enforced
9292    /// once per response rather than once per acknowledgement.
9293    #[cfg(test)]
9294    outcome_prune_count: Cell<usize>,
9295    /// Exact observer-transaction output drained by command/FFI hosts.
9296    progress: ProgressObserver,
9297    change_queue: VecDeque<ClientChangeBatch>,
9298    sync_intent_queue: VecDeque<SyncIntent>,
9299    /// Explicit exponential retry policy for transient transport failures.
9300    retry_delay_ms: u64,
9301    /// §7.6: the background retry the current round scheduled, if any.
9302    round_retry_delay_ms: Option<u64>,
9303    active_round: Option<uuid::Uuid>,
9304    last_round: Option<DiagnosticLastRound>,
9305    last_change: Option<DiagnosticLastChange>,
9306    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
9307    query_failures: Vec<DiagnosticQueryFailure>,
9308}
9309
9310pub(crate) fn quote_ident(name: &str) -> String {
9311    format!("\"{}\"", name.replace('"', "\"\""))
9312}
9313/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
9314/// previous-version module reads and writes the SAME records as the client
9315/// instead of growing a parallel meta layer.
9316pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
9317    conn.query_row(
9318        "SELECT value FROM _syncular_meta WHERE key = ?1",
9319        rusqlite::params![key],
9320        |row| row.get::<_, String>(0),
9321    )
9322    .ok()
9323}
9324
9325pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
9326    let _ = conn.execute(
9327        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
9328        rusqlite::params![key, value],
9329    );
9330}
9331
9332pub(crate) fn meta_delete(conn: &Connection, key: &str) {
9333    let _ = conn.execute(
9334        "DELETE FROM _syncular_meta WHERE key = ?1",
9335        rusqlite::params![key],
9336    );
9337}
9338
9339/// Seek by the existing key while retaining the exact legacy text match.
9340fn row_id_predicate(table: &TableSchema) -> String {
9341    let key = quote_ident(&table.primary_key);
9342    let text_match = format!("CAST({key} AS TEXT) = ?1");
9343    match table.columns[table.pk_index].ty {
9344        ColumnType::String | ColumnType::Json => {
9345            format!("{key} = ?1 AND {text_match}")
9346        }
9347        // Native columns have no declared SQLite affinity. Match the stored
9348        // integer bind type explicitly. Unary + removes expression affinity so
9349        // SQLite can seek the typeless key; the text check preserves exact IDs.
9350        ColumnType::Integer | ColumnType::Boolean => {
9351            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
9352        }
9353        // REAL-to-text conversion can round distinct values to the same text.
9354        // Narrowing those matches by numeric equality would change behavior.
9355        _ => text_match,
9356    }
9357}
9358
9359fn is_local_operation_code_like(value: &str) -> bool {
9360    let bytes = value.as_bytes();
9361    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
9362        && bytes
9363            .iter()
9364            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
9365}
9366
9367fn base_table(name: &str) -> String {
9368    quote_ident(&format!("_syncular_base_{name}"))
9369}
9370
9371/// §4.8: a stable, server-opaque key for a window base — table + variable +
9372/// canonical fixed scopes. Two `set_window` calls with the same base
9373/// address the same registry rows.
9374/// §4.8 deferred eviction record: (sub id, table, effective scope map).
9375type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
9376
9377fn window_base_key(base: &WindowBase) -> String {
9378    format!(
9379        "{}\0{}\0{}",
9380        base.table,
9381        base.variable,
9382        canonical_scope_json(&base.fixed_scopes)
9383    )
9384}
9385
9386/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
9387fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
9388    let mut scopes = base.fixed_scopes.clone();
9389    scopes.retain(|(k, _)| k != &base.variable);
9390    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
9391    scopes
9392}
9393
9394/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
9395/// echoed not interpreted by the server, so the exact hash is client
9396/// convention; SHA-256 matches the SPEC's worked example.
9397fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
9398    let canonical = canonical_scope_json(&unit_scopes(base, unit));
9399    let digest = Sha256::digest(canonical.as_bytes());
9400    let hex = bytes_to_hex(&digest);
9401    format!("w:{}:{}", base.table, &hex[..16])
9402}
9403
9404fn visible_table(name: &str) -> String {
9405    quote_ident(name)
9406}
9407
9408const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
9409
9410/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
9411/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
9412fn is_synced_table_name(name: &str) -> bool {
9413    if name.starts_with("sqlite_") {
9414        return false;
9415    }
9416    if name.starts_with("_syncular_base_") {
9417        return true; // the base half of a synced table pair
9418    }
9419    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
9420    !name.starts_with("_syncular_")
9421}
9422
9423/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
9424fn blob_id_for(bytes: &[u8]) -> String {
9425    let digest = Sha256::digest(bytes);
9426    format!("sha256:{}", bytes_to_hex(&digest))
9427}
9428
9429/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
9430/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
9431/// per row on the §5.6 bootstrap path), scalars bind owned.
9432enum RowParam<'a> {
9433    Cell(&'a Option<ColumnValue>),
9434    Version(i64),
9435}
9436
9437impl rusqlite::ToSql for RowParam<'_> {
9438    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
9439        Ok(match self {
9440            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
9441            RowParam::Cell(cell) => match cell {
9442                None => ToSqlOutput::Owned(SqlValue::Null),
9443                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
9444                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
9445                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
9446                Some(ColumnValue::Boolean(b)) => {
9447                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
9448                }
9449                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
9450                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
9451                }
9452                // §5.10: crdt bytes store as BLOB, like bytes.
9453                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
9454                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
9455                }
9456            },
9457        })
9458    }
9459}
9460
9461// Retain a typed primary key across the current import chunk without copying row payloads.
9462fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
9463    match param.to_sql().map_err(|error| error.to_string())? {
9464        ToSqlOutput::Owned(value) => Ok(value),
9465        ToSqlOutput::Borrowed(value) => {
9466            SqlValue::try_from(value).map_err(|error| error.to_string())
9467        }
9468        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
9469    }
9470}
9471
9472/// §5.3 image cell → bind parameter, strict per the declared column type
9473/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
9474/// nullable). Mismatches are image-producer violations. Returns the cell
9475/// borrowed when the stored representation already matches what the row
9476/// codec would write, or the normalized scalar (boolean → 0/1, float from
9477/// INTEGER → REAL) otherwise — the local write is byte-identical to the
9478/// old convert-then-insert path without allocating per cell.
9479fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
9480    use ssp2::segment::ColumnType;
9481    let mismatch = || {
9482        Err(format!(
9483            "image column {:?} holds a value of the wrong type",
9484            column.name
9485        ))
9486    };
9487    match value {
9488        ValueRef::Null => {
9489            if !column.nullable {
9490                return Err(format!(
9491                    "image column {:?} is NULL but not nullable",
9492                    column.name
9493                ));
9494            }
9495            Ok(ToSqlOutput::Owned(SqlValue::Null))
9496        }
9497        ValueRef::Integer(i) => match column.ty {
9498            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
9499            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
9500            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
9501            _ => mismatch(),
9502        },
9503        ValueRef::Real(_) => match column.ty {
9504            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
9505            _ => mismatch(),
9506        },
9507        ValueRef::Text(t) => {
9508            std::str::from_utf8(t)
9509                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
9510            match column.ty {
9511                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
9512                    Ok(ToSqlOutput::Borrowed(value))
9513                }
9514                _ => mismatch(),
9515            }
9516        }
9517        ValueRef::Blob(_) => match column.ty {
9518            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
9519            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
9520            _ => mismatch(),
9521        },
9522    }
9523}
9524
9525pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
9526    use rusqlite::types::ValueRef;
9527    match value {
9528        ValueRef::Null => Value::Null,
9529        ValueRef::Integer(i) => match column.ty {
9530            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
9531            ssp2::segment::ColumnType::Float => {
9532                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
9533            }
9534            _ => Value::from(i),
9535        },
9536        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9537        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9538        ValueRef::Blob(b) => {
9539            let mut map = Map::new();
9540            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9541            Value::Object(map)
9542        }
9543    }
9544}
9545
9546/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
9547/// Objects are accepted in lossless `{"$bytes": hex}` and
9548/// `{"$bigint": decimal}` envelope forms.
9549fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
9550    Ok(match value {
9551        Value::Null => SqlValue::Null,
9552        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
9553        Value::Number(n) => {
9554            if let Some(i) = n.as_i64() {
9555                SqlValue::Integer(i)
9556            } else if let Some(f) = n.as_f64() {
9557                SqlValue::Real(f)
9558            } else {
9559                return Err(format!("query param number {n} is out of range"));
9560            }
9561        }
9562        Value::String(s) => SqlValue::Text(s.clone()),
9563        Value::Object(_) => {
9564            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
9565                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
9566            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
9567                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
9568                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
9569                })?)
9570            } else {
9571                return Err(
9572                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
9573                        .to_owned(),
9574                );
9575            }
9576        }
9577        Value::Array(_) => return Err("query array params are not supported".to_owned()),
9578    })
9579}
9580
9581/// Map a rusqlite value with no schema column to consult (arbitrary query
9582/// output): integers/reals/text pass through by stored affinity, blobs ride
9583/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
9584/// schema column type to recover booleans/floats/json.
9585fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
9586    use rusqlite::types::ValueRef;
9587    match value {
9588        ValueRef::Null => Value::Null,
9589        ValueRef::Integer(i) => {
9590            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
9591            // ordinary UI-sized integers ergonomic and envelope only values
9592            // beyond JavaScript's safe range.
9593            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
9594            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
9595                Value::from(i)
9596            } else {
9597                let mut map = Map::new();
9598                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
9599                Value::Object(map)
9600            }
9601        }
9602        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9603        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9604        ValueRef::Blob(b) => {
9605            let mut map = Map::new();
9606            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9607            Value::Object(map)
9608        }
9609    }
9610}
9611
9612/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
9613fn query_connection(
9614    conn: &Connection,
9615    sql: &str,
9616    params: &[QueryValue],
9617) -> Result<Vec<QueryRow>, QueryReadFailure> {
9618    crate::query_guard::assert_read_only_query(sql)?;
9619    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
9620    let bound: Vec<SqlValue> = params
9621        .iter()
9622        .map(json_param_to_sql)
9623        .collect::<Result<_, _>>()?;
9624    let mut stmt = conn.prepare(&lowered_sql)?;
9625    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
9626    let bound_refs: Vec<&dyn rusqlite::ToSql> =
9627        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
9628    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
9629    let mut out = Vec::new();
9630    while let Some(row) = sql_rows.next()? {
9631        let mut record = Map::new();
9632        for (i, name) in column_names.iter().enumerate() {
9633            let value = row.get_ref(i)?;
9634            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
9635        }
9636        out.push(record);
9637    }
9638    Ok(out)
9639}
9640
9641fn persisted_window_state(
9642    conn: &Connection,
9643    base: &WindowBase,
9644) -> Result<WindowState, QueryReadFailure> {
9645    let mut stmt = conn.prepare(
9646        "SELECT windows.unit, subscriptions.state_json
9647           FROM _syncular_windows AS windows
9648           JOIN _syncular_subscriptions AS subscriptions
9649             ON subscriptions.id = windows.sub_id
9650          WHERE windows.base = ?1
9651          ORDER BY windows.unit ASC",
9652    )?;
9653    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
9654        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9655    })?;
9656    let mut units = Vec::new();
9657    let mut pending = Vec::new();
9658    for row in rows {
9659        let (unit, raw) = row?;
9660        let state: Value = serde_json::from_str(&raw)
9661            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
9662        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
9663            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
9664            || state
9665                .get("bootstrapState")
9666                .is_some_and(|value| !value.is_null());
9667        if is_pending {
9668            pending.push(unit.clone());
9669        }
9670        units.push(unit);
9671    }
9672    Ok(WindowState { units, pending })
9673}
9674
9675/// One read-only statement in a §7.5 snapshot read.
9676#[derive(Debug, Clone, Copy)]
9677pub struct SnapshotStatement<'a> {
9678    pub sql: &'a str,
9679    pub params: &'a [Value],
9680}
9681
9682/// A §7.5 atomic read request: read-only statements, window coverage,
9683/// subscription catch-up states, and commit deliveries, all resolved from one
9684/// SQLite read transaction and one revision.
9685#[derive(Debug, Clone, Default)]
9686pub struct SnapshotReadRequest<'a> {
9687    pub statements: Vec<SnapshotStatement<'a>>,
9688    pub coverage: &'a [WindowCoverage],
9689    pub subscriptions: Vec<String>,
9690    pub commit_ids: Vec<String>,
9691}
9692
9693#[cfg(test)]
9694thread_local! {
9695    /// Test-only rendezvous that runs between the statements of one snapshot
9696    /// read. A test uses it to commit on another connection mid-read and prove
9697    /// the read transaction's snapshot and revision stay stable.
9698    static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
9699        std::cell::RefCell::new(None);
9700}
9701
9702#[cfg(test)]
9703fn run_snapshot_read_hook() {
9704    SNAPSHOT_READ_HOOK.with(|slot| {
9705        if let Some(hook) = slot.borrow_mut().take() {
9706            hook();
9707        }
9708    });
9709}
9710
9711/// §7.5: resolve a request over one SQLite read transaction. The revision,
9712/// every statement's rows, coverage, catch-up states, and deliveries are
9713/// captured under one `SAVEPOINT`; any statement's failure rolls the whole
9714/// read back and releases the connection.
9715fn snapshot_read_connection(
9716    conn: &Connection,
9717    request: &SnapshotReadRequest<'_>,
9718) -> Result<SnapshotRead, QueryReadFailure> {
9719    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
9720    let corrupt = |message: &str| QueryReadFailure {
9721        code: Some("sync.local_corrupt"),
9722        sqlite_code: None,
9723        sqlite_message: None,
9724        rollback_failure: None,
9725        message: message.to_owned(),
9726    };
9727    let stored_failure = |error: rusqlite::Error, message: &str| match error {
9728        rusqlite::Error::Utf8Error(..)
9729        | rusqlite::Error::InvalidColumnType(..)
9730        | rusqlite::Error::FromSqlConversionFailure(..)
9731        | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
9732        _ => QueryReadFailure::from(error),
9733    };
9734    let result: Result<SnapshotRead, QueryReadFailure> = (|| {
9735        // The durable revision as a canonical decimal `u64`; a missing marker
9736        // is the legacy zero, and any other non-canonical value is corrupt.
9737        let revision: Option<String> = conn
9738            .query_row(
9739                "SELECT value FROM _syncular_meta WHERE key = ?1",
9740                rusqlite::params![LOCAL_REVISION_KEY],
9741                |row| row.get(0),
9742            )
9743            .optional()
9744            .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
9745        let revision = match revision {
9746            None => "0".to_owned(),
9747            Some(value)
9748                if value == "0"
9749                    || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
9750                        && value.bytes().all(|b| b.is_ascii_digit())
9751                        && value.parse::<u64>().is_ok()) =>
9752            {
9753                value
9754            }
9755            Some(_) => return Err(corrupt("persisted local revision is invalid")),
9756        };
9757        let mut queries = Vec::with_capacity(request.statements.len());
9758        for statement in &request.statements {
9759            queries.push(query_connection(conn, statement.sql, statement.params)?);
9760            #[cfg(test)]
9761            run_snapshot_read_hook();
9762        }
9763        let mut pending = Vec::new();
9764        let mut missing = Vec::new();
9765        for requested in request.coverage {
9766            let base_key = window_base_key(&requested.base);
9767            let state = persisted_window_state(conn, &requested.base)?;
9768            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
9769                let reference = WindowUnitRef {
9770                    base_key: base_key.clone(),
9771                    unit: unit.clone(),
9772                };
9773                if !state.units.iter().any(|held| held == &unit) {
9774                    missing.push(reference);
9775                } else if state.pending.iter().any(|held| held == &unit) {
9776                    pending.push(reference);
9777                }
9778            }
9779        }
9780        let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
9781        for id in &request.subscriptions {
9782            let row: Option<(String, String)> = conn
9783                .query_row(
9784                    "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
9785                    rusqlite::params![id],
9786                    |row| Ok((row.get(0)?, row.get(1)?)),
9787                )
9788                .optional()
9789                .map_err(|error| {
9790                    stored_failure(error, "persisted subscription state is invalid")
9791                })?;
9792            let Some((table, raw)) = row else {
9793                subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
9794                continue;
9795            };
9796            // Every persisted field is required to be well typed; a corrupt
9797            // record fails typed instead of inventing a state.
9798            let state: Value = serde_json::from_str(&raw)
9799                .map_err(|_| corrupt("persisted subscription state is invalid"))?;
9800            let status = match state.get("status").and_then(Value::as_str) {
9801                Some(value @ ("active" | "revoked" | "failed")) => value,
9802                _ => return Err(corrupt("persisted subscription state is invalid")),
9803            };
9804            let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
9805                return Err(corrupt("persisted subscription state is invalid"));
9806            };
9807            if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
9808                return Err(corrupt("persisted subscription state is invalid"));
9809            }
9810            let effective_scopes = state
9811                .get("effectiveScopes")
9812                .filter(|value| !value.is_null());
9813            if let Some(scopes) = effective_scopes {
9814                json_to_scope_map(scopes)
9815                    .map_err(|_| corrupt("persisted subscription state is invalid"))?;
9816            }
9817            let has_resume_token = match state.get("bootstrapState") {
9818                None | Some(Value::Null) => false,
9819                Some(Value::String(_)) => true,
9820                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
9821            };
9822            // A present reasonCode must be a string; a non-string value is a
9823            // corrupt record, never silently dropped.
9824            let reason_code = match state.get("reasonCode") {
9825                None | Some(Value::Null) => None,
9826                Some(Value::String(value)) => Some(value.clone()),
9827                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
9828            };
9829            let active = status == "active";
9830            subscriptions.push(SubscriptionCatchup::Known {
9831                id: id.clone(),
9832                table,
9833                status: status.to_owned(),
9834                cursor,
9835                has_resume_token,
9836                bootstrap_complete: active && cursor >= 0 && !has_resume_token,
9837                known_pending_pages: active && (has_resume_token || cursor < 0),
9838                effective_scopes: effective_scopes.cloned(),
9839                reason_code,
9840            });
9841        }
9842        let mut deliveries = Vec::with_capacity(request.commit_ids.len());
9843        for id in &request.commit_ids {
9844            let pending: bool = conn.query_row(
9845                "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
9846                rusqlite::params![id],
9847                |row| row.get(0),
9848            )?;
9849            if pending {
9850                deliveries.push(CommitDelivery::Pending {
9851                    client_commit_id: id.clone(),
9852                });
9853                continue;
9854            }
9855            let row = conn
9856                .query_row(
9857                    "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
9858                            resolution, resolved_at_ms, replacement_client_commit_id
9859                       FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
9860                    rusqlite::params![id],
9861                    StoredCommitOutcomeRow::from_row,
9862                )
9863                .optional()
9864                .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
9865            deliveries.push(match row {
9866                Some(row) => CommitDelivery::Known {
9867                    client_commit_id: id.clone(),
9868                    outcome: SyncClient::outcome_from_row(row)
9869                        .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
9870                },
9871                None => CommitDelivery::Unknown {
9872                    client_commit_id: id.clone(),
9873                },
9874            });
9875        }
9876        Ok(SnapshotRead {
9877            revision,
9878            queries,
9879            coverage: CoverageSnapshot {
9880                complete: pending.is_empty() && missing.is_empty(),
9881                pending,
9882                missing,
9883            },
9884            subscriptions,
9885            deliveries,
9886        })
9887    })();
9888    match result {
9889        Ok(read) => {
9890            conn.execute_batch("RELEASE syncular_snapshot_read")?;
9891            Ok(read)
9892        }
9893        Err(mut error) => {
9894            if let Err(rollback) = conn
9895                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
9896            {
9897                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
9898            }
9899            Err(error)
9900        }
9901    }
9902}
9903
9904fn snapshot_connection(
9905    conn: &Connection,
9906    sql: &str,
9907    params: &[Value],
9908    coverage: &[WindowCoverage],
9909) -> Result<QuerySnapshot, QueryReadFailure> {
9910    let read = snapshot_read_connection(
9911        conn,
9912        &SnapshotReadRequest {
9913            statements: vec![SnapshotStatement { sql, params }],
9914            coverage,
9915            subscriptions: Vec::new(),
9916            commit_ids: Vec::new(),
9917        },
9918    )?;
9919    Ok(QuerySnapshot {
9920        revision: read.revision,
9921        rows: read
9922            .queries
9923            .into_iter()
9924            .next()
9925            .expect("single-query snapshot returns one result"),
9926        coverage: read.coverage,
9927    })
9928}
9929
9930/// A long-lived read-only SQLite sidecar for latency-critical native views.
9931/// Network rounds stay serialized on the mutable core owner, while atomic
9932/// query snapshots use this independent connection and therefore never queue
9933/// behind HTTP/WebSocket latency.
9934pub struct FileQuerySnapshotReader {
9935    path: String,
9936    conn: Option<Connection>,
9937}
9938
9939impl FileQuerySnapshotReader {
9940    #[must_use]
9941    pub fn new(path: impl Into<String>) -> Self {
9942        Self {
9943            path: path.into(),
9944            conn: None,
9945        }
9946    }
9947
9948    fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
9949        if self.conn.is_none() {
9950            let conn = Connection::open_with_flags(
9951                &self.path,
9952                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
9953            )
9954            .map_err(QueryReadFailure::from)?;
9955            conn.busy_timeout(std::time::Duration::from_millis(250))
9956                .map_err(QueryReadFailure::from)?;
9957            self.conn = Some(conn);
9958        }
9959        self.conn
9960            .as_ref()
9961            .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
9962    }
9963
9964    /// One atomic read over the shared primitive: every statement, coverage,
9965    /// catch-up, and delivery under one `SAVEPOINT` and one revision.
9966    pub fn snapshot_read(
9967        &mut self,
9968        request: &SnapshotReadRequest<'_>,
9969    ) -> Result<SnapshotRead, QueryReadFailure> {
9970        snapshot_read_connection(self.connection()?, request)
9971    }
9972
9973    /// The current durable local revision. A host can skip a read when it is
9974    /// unchanged; a corrupt or unreadable marker fails typed.
9975    pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
9976        Ok(self
9977            .snapshot_read(&SnapshotReadRequest::default())?
9978            .revision)
9979    }
9980
9981    /// One subscription's persisted catch-up state (see the §7.5 contract).
9982    pub fn subscription_catchup(
9983        &mut self,
9984        id: &str,
9985    ) -> Result<SubscriptionCatchup, QueryReadFailure> {
9986        let read = self.snapshot_read(&SnapshotReadRequest {
9987            subscriptions: vec![id.to_owned()],
9988            ..Default::default()
9989        })?;
9990        Ok(read
9991            .subscriptions
9992            .into_iter()
9993            .next()
9994            .expect("one requested catch-up"))
9995    }
9996
9997    /// One client commit's delivery status: pending, its retained outcome, or
9998    /// an explicit unknown (never held, or pruned by retention).
9999    pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
10000        let read = self.snapshot_read(&SnapshotReadRequest {
10001            commit_ids: vec![id.to_owned()],
10002            ..Default::default()
10003        })?;
10004        Ok(read
10005            .deliveries
10006            .into_iter()
10007            .next()
10008            .expect("one requested delivery"))
10009    }
10010
10011    /// Hosts report an owned failure, and the first success after it, to the
10012    /// owning core through [`SyncClient::record_query_read`] (§7.6).
10013    pub fn query_snapshot(
10014        &mut self,
10015        sql: &str,
10016        params: &[Value],
10017        coverage: &[WindowCoverage],
10018    ) -> Result<QuerySnapshot, QueryReadFailure> {
10019        snapshot_connection(self.connection()?, sql, params, coverage)
10020    }
10021}
10022
10023fn validate_authority_reads(
10024    schema: &ClientSchema,
10025    declarations: &[crate::api::AuthorityReadDeclaration],
10026) -> Result<(), String> {
10027    let mut seen = BTreeSet::new();
10028    for read in declarations {
10029        let table = schema
10030            .table(&read.table)
10031            .ok_or("client.authority_read_forbidden: authority table is not declared")?;
10032        let plain = |name: &str| {
10033            table.columns.iter().enumerate().any(|(i, c)| {
10034                c.name == name
10035                    && name != "authority_version"
10036                    && !name.starts_with("_sync")
10037                    && !table.encrypted_columns.iter().any(|e| e.index == i)
10038                    && matches!(
10039                        c.ty,
10040                        ssp2::segment::ColumnType::String
10041                            | ssp2::segment::ColumnType::Integer
10042                            | ssp2::segment::ColumnType::Float
10043                            | ssp2::segment::ColumnType::Boolean
10044                            | ssp2::segment::ColumnType::Json
10045                    )
10046            })
10047        };
10048        if !seen.insert(&read.table)
10049            || read.columns.is_empty()
10050            || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
10051            || !read.columns.contains(&table.primary_key)
10052            || !read.columns.iter().all(|c| plain(c))
10053            || read.scopes.is_empty()
10054            || !read.scopes.iter().all(|(v, values)| {
10055                table
10056                    .scope_column(v)
10057                    .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
10058                    && !values.is_empty()
10059                    && values.iter().all(|v| !v.is_empty() && v != "*")
10060            })
10061        {
10062            return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
10063        }
10064    }
10065    Ok(())
10066}
10067
10068// §7.4.2: every open and recreation checks before any persistent writes.
10069/// A marker read that hit a recognized SQLite storage failure keeps that
10070/// classification (BUSY/LOCKED stay retryable); only an unclassified read
10071/// failure is the invalid-marker `sync.local_corrupt`.
10072fn schema_marker_read_failure(error: rusqlite::Error) -> String {
10073    let failure = QueryReadFailure::from(error);
10074    if failure.code.is_some() {
10075        failure.to_string()
10076    } else {
10077        "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
10078    }
10079}
10080
10081/// §7.1: reject an invalid configured client limit before any storage is
10082/// opened or written. Zero would wedge the push queue and an out-of-range
10083/// value would differ per target; the TS core enforces the same bounds.
10084fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
10085    if limits.outcome_retention_max_entries == Some(0) {
10086        return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
10087    }
10088    if [
10089        limits.max_push_commits_per_request,
10090        limits.max_push_operations_per_request,
10091        limits.max_push_request_bytes,
10092    ]
10093    .into_iter()
10094    .flatten()
10095    .any(|value| value == 0 || value > u32::MAX as usize)
10096    {
10097        return Err(
10098            "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
10099        );
10100    }
10101    Ok(())
10102}
10103
10104fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
10105    let corrupt =
10106        || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
10107    let table_type: Option<String> = conn
10108        .query_row(
10109            "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
10110            [],
10111            |row| row.get(0),
10112        )
10113        .optional()
10114        .map_err(schema_marker_read_failure)?;
10115    let Some(table_type) = table_type else {
10116        return Ok(None);
10117    };
10118    if table_type != "table" {
10119        return Err(corrupt());
10120    }
10121    // §7.4.2: a duplicated marker cannot be resolved. `query_row` would take
10122    // whichever row SQLite returns first, and an older one would drive a
10123    // destructive reset over a newer replica.
10124    let mut statement = conn
10125        .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
10126        .map_err(schema_marker_read_failure)?;
10127    let mut rows = statement
10128        .query([LOCAL_SCHEMA_VERSION_KEY])
10129        .map_err(schema_marker_read_failure)?;
10130    let value: Option<String> = rows
10131        .next()
10132        .map_err(schema_marker_read_failure)?
10133        .map(|row| row.get(0))
10134        .transpose()
10135        .map_err(schema_marker_read_failure)?;
10136    if rows.next().map_err(schema_marker_read_failure)?.is_some() {
10137        return Err(corrupt());
10138    }
10139    let Some(value) = value else {
10140        let has_descriptor = conn
10141            .query_row(
10142                "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
10143                [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
10144                |_| Ok(()),
10145            )
10146            .optional()
10147            .map_err(schema_marker_read_failure)?
10148            .is_some();
10149        if has_descriptor {
10150            return Err(corrupt());
10151        }
10152        return Ok(None);
10153    };
10154    if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10155        || !value.bytes().all(|b| b.is_ascii_digit())
10156    {
10157        return Err(corrupt());
10158    }
10159    let version = value.parse::<i32>().map_err(|_| corrupt())?;
10160    if version > requested {
10161        return Err(
10162            "client.schema_downgrade: persisted local schema is newer than the requested schema"
10163                .to_owned(),
10164        );
10165    }
10166    Ok(Some(version))
10167}
10168
10169impl SyncClient {
10170    pub fn new_with_identity(
10171        client_id: Option<String>,
10172        schema_json: &Value,
10173        limits: ClientLimits,
10174    ) -> Result<Self, String> {
10175        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
10176        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10177        Self::with_connection(resolved, schema_json, limits, conn)
10178    }
10179
10180    pub fn new(
10181        client_id: String,
10182        schema_json: &Value,
10183        limits: ClientLimits,
10184    ) -> Result<Self, String> {
10185        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10186        Self::with_connection(client_id, schema_json, limits, conn)
10187    }
10188
10189    /// Build a client backed by an on-disk SQLite database at `path` — the
10190    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
10191    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
10192    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
10193    /// of the command router's dependency set (the router only holds a path).
10194    pub fn open_path(
10195        client_id: String,
10196        schema_json: &Value,
10197        limits: ClientLimits,
10198        path: &str,
10199    ) -> Result<Self, String> {
10200        validate_client_limits(&limits)?;
10201        parse_schema_json(schema_json)?;
10202        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10203        Self::with_connection(client_id, schema_json, limits, conn)
10204    }
10205
10206    pub fn open_path_with_identity(
10207        client_id: Option<String>,
10208        schema_json: &Value,
10209        limits: ClientLimits,
10210        path: &str,
10211    ) -> Result<Self, String> {
10212        validate_client_limits(&limits)?;
10213        // §7.4.2: an invalid requested version is refused before the replica is
10214        // even created, so a rejected constructor leaves no file behind.
10215        parse_schema_json(schema_json)?;
10216        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10217        // A file-backed native client uses an independent read connection for
10218        // latency-critical snapshots. WAL is SQLite's intended reader/writer
10219        // concurrency mode: a view read never holds a rollback-journal lock
10220        // that delays the client's next commit. The busy timeout must precede
10221        // this replica's own startup transaction; WAL is applied only after
10222        // startup succeeded, because a refused open must leave the replica's
10223        // journal mode and contents untouched (§7.4.2).
10224        conn.busy_timeout(std::time::Duration::from_millis(250))
10225            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
10226        let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
10227        let journal_mode: String = client
10228            .conn
10229            .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
10230            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10231        if journal_mode != "wal" {
10232            return Err(
10233                "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
10234            );
10235        }
10236        Ok(client)
10237    }
10238
10239    #[must_use]
10240    pub fn client_id(&self) -> &str {
10241        &self.client_id
10242    }
10243
10244    /// Build a client over a caller-supplied rusqlite connection — the seam a
10245    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
10246    /// with an on-disk database (`Connection::open(path)`) rather than the
10247    /// default `:memory:`. The connection MUST be fresh (no pre-existing
10248    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
10249    /// the same file across process restarts reuses the persisted rows.
10250    pub fn with_connection(
10251        client_id: String,
10252        schema_json: &Value,
10253        limits: ClientLimits,
10254        conn: Connection,
10255    ) -> Result<Self, String> {
10256        Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
10257    }
10258
10259    /// [`Self::with_connection`] for `open_path_with_identity`: no requested id
10260    /// means a durable replica's persisted `clientId` wins over a fresh one.
10261    fn with_connection_identity(
10262        client_id: Option<String>,
10263        schema_json: &Value,
10264        limits: ClientLimits,
10265        conn: Connection,
10266    ) -> Result<Self, String> {
10267        validate_client_limits(&limits)?;
10268        let schema = parse_schema_json(schema_json)?;
10269        validate_authority_reads(&schema, &limits.authority_reads)?;
10270        // RFC 0005 D8: resolve the previous-version config before the opening
10271        // reset runs — the capture happens inside it. Bad bounds fail loud.
10272        let previous_version = match limits.previous_version_context {
10273            Some(config) => {
10274                config.validate()?;
10275                Some(config)
10276            }
10277            None => None,
10278        };
10279        let mut client = SyncClient {
10280            #[cfg(feature = "bench-internals")]
10281            benchmark_phases: Recorder::default(),
10282            conn,
10283            schema,
10284            client_id: client_id
10285                .clone()
10286                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
10287            limits,
10288            subs: Vec::new(),
10289            outbox: Vec::new(),
10290            failed_commits: Vec::new(),
10291            retain_failed_commits: false,
10292            conflicts: Vec::new(),
10293            rejections: Vec::new(),
10294            schema_floor: None,
10295            lease_state: None,
10296            stopped: false,
10297            upgrading: false,
10298            sync_needed: false,
10299            realtime_policy: RealtimePolicy::Optional,
10300            realtime_state: RealtimeState::Disconnected,
10301            realtime_reason_code: None,
10302            realtime_retry_delay_ms: None,
10303            presence: HashMap::new(),
10304            now_ms: None,
10305            encryption: crate::values::EncryptionConfig::default(),
10306            security_preflight: false,
10307            transport_enabled: true,
10308            previous_version,
10309            insert_sql: RefCell::new(HashMap::new()),
10310            overlay_dirty: OverlayDirty::clean(),
10311            storage_failure: RefCell::new(None),
10312            #[cfg(test)]
10313            overlay_rebuild_count: Cell::new(0),
10314            #[cfg(test)]
10315            acknowledged_replay_count: Cell::new(0),
10316            #[cfg(test)]
10317            outcome_prune_count: Cell::new(0),
10318            progress: ProgressObserver::default(),
10319            change_queue: VecDeque::new(),
10320            sync_intent_queue: VecDeque::new(),
10321            retry_delay_ms: 250,
10322            round_retry_delay_ms: None,
10323            active_round: None,
10324            last_round: None,
10325            last_change: None,
10326            query_failures: Vec::new(),
10327        };
10328        // The row write path leans on the prepared-statement cache (two
10329        // insert statements per synced table, plus the bookkeeping
10330        // statements); size it so a multi-table schema never thrashes.
10331        client
10332            .conn
10333            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
10334        // §7.4.2: one write transaction (SQLite `BEGIN IMMEDIATE`, a reserved
10335        // writer) covers the marker read, the identity check, and every
10336        // bookkeeping/schema write, so a concurrent open that upgraded the
10337        // replica cannot interleave between the guard and the writes, and a
10338        // refusal leaves the replica unchanged.
10339        client
10340            .conn
10341            .execute_batch("BEGIN IMMEDIATE")
10342            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10343        let startup = (|| -> Result<(), String> {
10344            // The marker guard runs inside the write transaction, so the version
10345            // it validates is the version every write below commits under.
10346            let marker = read_local_schema_version(&client.conn, client.schema.version)?;
10347            // New app indexes may reference columns that only exist after a
10348            // version-bump reset.
10349            client.create_bookkeeping_tables()?;
10350            match client.get_meta(CLIENT_ID_KEY) {
10351                Some(existing) => match client_id {
10352                    Some(requested) if existing != requested => {
10353                        return Err(format!(
10354                            "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
10355                        ))
10356                    }
10357                    _ => client.client_id = existing,
10358                },
10359                None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
10360            }
10361            client.restore_persisted_state()?;
10362            client.failed_commits = client.load_failed_commits()?;
10363            match marker {
10364                None => {
10365                    client.create_synced_tables()?;
10366                    client.persist_schema_version()?;
10367                    client.save_subscription_scope_schema();
10368                }
10369                Some(version) if version == client.schema.version => {
10370                    client.create_synced_tables()?;
10371                    // RFC 0005 D1: the same-version open backfills the descriptor
10372                    // for a database first opened by an unaware binary, so the
10373                    // NEXT bump can capture without needing a schema bump first.
10374                    set_local_schema_descriptor(&client.conn, &client.schema)
10375                        .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10376                    client.save_subscription_scope_schema();
10377                }
10378                Some(_) => client.run_schema_reset()?,
10379            }
10380            // RFC 0005 D9: the reset sweep cannot see a container whose capture
10381            // committed in its OWN file while the replica's savepoint never
10382            // released — a crash at that seam leaves a container beside a matching
10383            // OLD marker, and the next open at that version runs no reset. Discard
10384            // it before anything can read it. No-op when no container exists.
10385            client.reconcile_previous_version_at_boot()?;
10386            client.clear_satisfied_persisted_schema_floor();
10387            client.prune_unknown_subscriptions(false)?;
10388            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
10389            // the full startup/reset reconciliation boundary, inside this open
10390            // transaction and after the schema and identity guards, so a commit
10391            // an earlier version accepted is dropped with durable rejection
10392            // evidence before any replay. A table the current schema removed
10393            // keeps the §7.4.4 send-time classification, and an unresolved
10394            // encryption key stays with the send seam because hosts configure
10395            // keys after open.
10396            client.drop_unencodable_outbox(false, None)?;
10397            if marker == Some(client.schema.version)
10398                && (!client.outbox.is_empty()
10399                    || !client.failed_commits.is_empty()
10400                    || client.has_acknowledged_rows())
10401            {
10402                // Reconstruct the visible optimistic overlay from the durable
10403                // base plus outbox instead of trusting a process-interrupted
10404                // mirror.
10405                client.overlay_dirty.set(true);
10406                client.rebuild_overlay()?;
10407            }
10408            // Every persisted active subscription needs one catch-up pull on
10409            // open: realtime only covers changes after connection, while an
10410            // idempotent setWindow correctly creates no fresh command effect.
10411            // Pending outbox work has the same restart requirement. The core
10412            // owns this intent so native hosts never poll or require an
10413            // application-issued sync().
10414            client.enqueue_startup_sync_if_needed()?;
10415            Ok(())
10416        })();
10417        match startup {
10418            Ok(()) => match client.conn.execute_batch("COMMIT") {
10419                Ok(()) => Ok(client),
10420                Err(error) => {
10421                    let failure = Self::sqlite_failure(&client.storage_failure, error);
10422                    if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10423                        // Keep a failed rollback's typed storage classification.
10424                        let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10425                    }
10426                    Err(failure)
10427                }
10428            },
10429            Err(error) => {
10430                if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10431                    // Keep a failed rollback's typed storage classification.
10432                    let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10433                }
10434                Err(error)
10435            }
10436        }
10437    }
10438
10439    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
10440    /// against this instead of system time (conformance virtual clock).
10441    pub fn set_now_ms(&mut self, now_ms: i64) {
10442        self.now_ms = Some(now_ms);
10443    }
10444
10445    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
10446    /// The command router parses these from the `create` command's
10447    /// `encryption` config (keys as `{$bytes: hex}`).
10448    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
10449        self.encryption = encryption;
10450    }
10451
10452    #[must_use]
10453    pub fn security_lifecycle(&self) -> &'static str {
10454        if self.security_preflight {
10455            "preflight"
10456        } else {
10457            "active"
10458        }
10459    }
10460
10461    #[must_use]
10462    pub fn security_preflight(&self) -> bool {
10463        self.security_preflight
10464    }
10465
10466    /// Host-owned, non-persisted network gate. Local operations remain available.
10467    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
10468        if self.transport_enabled != enabled {
10469            self.transport_enabled = enabled;
10470            self.sync_intent_queue.clear();
10471            self.sync_intent_queue
10472                .push_back(if enabled && !self.security_preflight {
10473                    SyncIntent::Interactive
10474                } else {
10475                    SyncIntent::None
10476                });
10477        }
10478        // A captured reply keeps its normal apply/revocation semantics.
10479        if !enabled && self.active_round.is_none() {
10480            self.disconnect_realtime(transport);
10481        }
10482    }
10483
10484    pub fn transport_enabled(&self) -> bool {
10485        self.transport_enabled
10486    }
10487
10488    /// Quarantine this replica: enter the fail-closed gate, release all
10489    /// core-owned key material, and record the gate in the database so
10490    /// reopening re-enters preflight until `activate_security` clears it.
10491    ///
10492    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
10493    /// which must NOT leave that durable mark.
10494    pub fn begin_security_preflight(&mut self) {
10495        self.seal_security_on_teardown();
10496        // Persist the quarantine so it survives handle teardown and restart:
10497        // reopening this replica re-enters preflight until activation clears it.
10498        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
10499    }
10500
10501    /// Release core-owned key material as a host tears the client down,
10502    /// WITHOUT recording a quarantine.
10503    ///
10504    /// Shutting an activated client down is not a quarantine event. Persisting
10505    /// the gate here marks every cleanly closed replica as pending, and the
10506    /// next plain `create` is then refused permanently: the reopen path
10507    /// restores the flag from the marker and the create guard rejects it. The
10508    /// in-memory flag still closes the gate for anything still holding this
10509    /// instance, which is all a teardown barrier needs.
10510    pub fn seal_security_on_teardown(&mut self) {
10511        self.cancel_sync_round();
10512        self.security_preflight = true;
10513        self.encryption = crate::values::EncryptionConfig::default();
10514        self.sync_intent_queue.clear();
10515    }
10516
10517    /// Install the post-authentication keyring and release the host loop.
10518    pub fn activate_security(
10519        &mut self,
10520        encryption: crate::values::EncryptionConfig,
10521    ) -> Result<(), String> {
10522        if !self.security_preflight {
10523            return Err(
10524                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
10525            );
10526        }
10527        self.enqueue_startup_sync_if_needed()?;
10528        self.encryption = encryption;
10529        self.security_preflight = false;
10530        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
10531        Ok(())
10532    }
10533
10534    fn clock_now_ms(&self) -> i64 {
10535        self.now_ms.unwrap_or_else(|| {
10536            std::time::SystemTime::now()
10537                .duration_since(std::time::UNIX_EPOCH)
10538                .map(|d| d.as_millis() as i64)
10539                .unwrap_or(0)
10540        })
10541    }
10542
10543    fn create_bookkeeping_tables(&self) -> Result<(), String> {
10544        // Durable client bookkeeping (outbox + subscription + meta).
10545        self.conn
10546            .execute_batch(
10547                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
10548                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
10549                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
10550                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
10551                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
10552                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
10553                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
10554                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
10555                   PRIMARY KEY(commit_id, idx));
10556                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
10557                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
10558                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
10559                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
10560                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
10561                   client_commit_id TEXT NOT NULL UNIQUE,
10562                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
10563                   recorded_at_ms INTEGER NOT NULL,
10564                   results_json TEXT NOT NULL,
10565                   operations_json TEXT,
10566                   resolution TEXT NOT NULL DEFAULT 'active'
10567                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
10568                   resolved_at_ms INTEGER,
10569                   replacement_client_commit_id TEXT);
10570                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
10571                   ON _syncular_commit_outcomes(resolution, seq);
10572                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
10573                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
10574                 CREATE TABLE IF NOT EXISTS _syncular_meta (
10575                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
10576                 CREATE TABLE IF NOT EXISTS _syncular_windows (
10577                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
10578                   PRIMARY KEY (base, unit));
10579                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
10580                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
10581                   effective_scopes TEXT NOT NULL);
10582                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
10583                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
10584                   PRIMARY KEY(commit_id, blob_id));
10585                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
10586                   ON _syncular_blob_commit_refs(blob_id);",
10587            )
10588            .map_err(|e| e.to_string())?;
10589        // Migrate an outcome journal created before failed aggregate
10590        // envelopes were retained. Historical rows intentionally stay NULL.
10591        let _ = self
10592            .conn
10593            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
10594        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
10595            self.set_meta(LOCAL_REVISION_KEY, "0");
10596        }
10597        // §5.9.7 blob cache. Mutable upload state stays off the body row so
10598        // completing a large upload does not rewrite its BLOB record.
10599        if self.schema_has_blobs() {
10600            let exists = self
10601                .conn
10602                .query_row(
10603                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
10604                    [],
10605                    |row| row.get::<_, bool>(0),
10606                )
10607                .map_err(|e| e.to_string())?;
10608            if exists {
10609                let mut statement = self
10610                    .conn
10611                    .prepare("PRAGMA table_info(_syncular_blobs)")
10612                    .map_err(|e| e.to_string())?;
10613                let mut columns = statement
10614                    .query_map([], |row| row.get::<_, String>(1))
10615                    .map_err(|e| e.to_string())?
10616                    .collect::<Result<Vec<_>, _>>()
10617                    .map_err(|e| e.to_string())?;
10618                columns.sort();
10619                let mut expected = vec![
10620                    "blob_id",
10621                    "byte_length",
10622                    "bytes",
10623                    "created_at_ms",
10624                    "media_type",
10625                ];
10626                expected.sort_unstable();
10627                if columns != expected {
10628                    return Err(
10629                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
10630                    );
10631                }
10632            } else {
10633                self.conn
10634                    .execute_batch(
10635                        "CREATE TABLE _syncular_blobs(
10636                           blob_id TEXT PRIMARY KEY,
10637                           bytes BLOB NOT NULL,
10638                           byte_length INTEGER NOT NULL,
10639                           media_type TEXT,
10640                           created_at_ms INTEGER NOT NULL);",
10641                    )
10642                    .map_err(|e| e.to_string())?;
10643            }
10644            self.conn
10645                .execute_batch(
10646                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
10647                       blob_id TEXT PRIMARY KEY,
10648                       media_type TEXT,
10649                       created_at_ms INTEGER NOT NULL);",
10650                )
10651                .map_err(|e| e.to_string())?;
10652        }
10653        Ok(())
10654    }
10655
10656    /// True iff any synced table declares a `blob_ref` column (§5.9).
10657    fn schema_has_blobs(&self) -> bool {
10658        self.schema
10659            .tables
10660            .iter()
10661            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
10662    }
10663
10664    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
10665
10666    fn get_meta(&self, key: &str) -> Option<String> {
10667        meta_get(&self.conn, key)
10668    }
10669
10670    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
10671        self.conn
10672            .query_row(
10673                "SELECT value FROM _syncular_meta WHERE key = ?1",
10674                rusqlite::params![key],
10675                |row| row.get::<_, String>(0),
10676            )
10677            .optional()
10678            .map_err(|_| {
10679                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
10680            })
10681    }
10682
10683    // The caller holds the startup/reset transaction. Either both schema
10684    // records persist or the enclosing operation fails and rolls back.
10685    fn persist_schema_version(&self) -> Result<(), String> {
10686        self.conn
10687            .execute(
10688                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
10689                rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
10690            )
10691            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10692        set_local_schema_descriptor(&self.conn, &self.schema)
10693            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
10694    }
10695
10696    fn set_meta(&self, key: &str, value: &str) {
10697        meta_set(&self.conn, key, value);
10698    }
10699
10700    fn delete_meta(&self, key: &str) {
10701        meta_delete(&self.conn, key);
10702    }
10703
10704    /// §7.1: the durable outbox in FIFO order, in schema-agnostic local form.
10705    fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
10706        let mut stmt = self
10707            .conn
10708            .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
10709            .map_err(|error| error.to_string())?;
10710        let rows = stmt
10711            .query_map([], |row| {
10712                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10713            })
10714            .map_err(|error| error.to_string())?;
10715        let mut commits = Vec::new();
10716        for row in rows {
10717            let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
10718            let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
10719                format!("invalid persisted outbox {client_commit_id:?}: {error}")
10720            })?;
10721            let mut ops = Vec::with_capacity(entries.len());
10722            for entry in entries {
10723                let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
10724                ops.push(OutboxOp {
10725                    upsert: op == "upsert",
10726                    table: entry
10727                        .get("table")
10728                        .and_then(Value::as_str)
10729                        .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
10730                        .to_owned(),
10731                    row_id: entry
10732                        .get("rowId")
10733                        .and_then(Value::as_str)
10734                        .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
10735                        .to_owned(),
10736                    base_version: entry.get("baseVersion").and_then(Value::as_i64),
10737                    // A legacy entry's `changedFields` key is ignored:
10738                    // the values map is the presence set (§6.1).
10739                    values: entry.get("values").and_then(Value::as_object).cloned(),
10740                });
10741            }
10742            commits.push(OutboxCommit {
10743                client_commit_id,
10744                ops,
10745            });
10746        }
10747        Ok(commits)
10748    }
10749
10750    fn restore_persisted_state(&mut self) -> Result<(), String> {
10751        self.subs = {
10752            let mut stmt = self
10753                .conn
10754                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
10755                .map_err(|error| error.to_string())?;
10756            let rows = stmt
10757                .query_map([], |row| {
10758                    Ok((
10759                        row.get::<_, String>(0)?,
10760                        row.get::<_, String>(1)?,
10761                        row.get::<_, String>(2)?,
10762                    ))
10763                })
10764                .map_err(|error| error.to_string())?;
10765            let mut subscriptions = Vec::new();
10766            for row in rows {
10767                let (id, table, raw) = row.map_err(|error| error.to_string())?;
10768                let state: Value = serde_json::from_str(&raw)
10769                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
10770                let requested = json_to_scope_map(
10771                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
10772                )?;
10773                let effective = state
10774                    .get("effectiveScopes")
10775                    .filter(|value| !value.is_null())
10776                    .map(json_to_scope_map)
10777                    .transpose()?;
10778                subscriptions.push(Subscription {
10779                    id,
10780                    table,
10781                    requested,
10782                    params: state
10783                        .get("params")
10784                        .and_then(Value::as_str)
10785                        .map(str::to_owned),
10786                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
10787                    bootstrap_state: state
10788                        .get("bootstrapState")
10789                        .and_then(Value::as_str)
10790                        .map(str::to_owned),
10791                    state: SubState::parse(
10792                        state
10793                            .get("status")
10794                            .and_then(Value::as_str)
10795                            .unwrap_or("active"),
10796                    ),
10797                    reason_code: state
10798                        .get("reasonCode")
10799                        .and_then(Value::as_str)
10800                        .map(str::to_owned),
10801                    effective,
10802                    synced_once: state
10803                        .get("syncedOnce")
10804                        .and_then(Value::as_bool)
10805                        .unwrap_or(false),
10806                });
10807            }
10808            subscriptions
10809        };
10810
10811        self.outbox = self.load_outbox()?;
10812
10813        self.prune_commit_outcomes()?;
10814        let active = self.commit_outcomes(CommitOutcomeQuery {
10815            active_only: true,
10816            ..CommitOutcomeQuery::default()
10817        })?;
10818        self.conflicts = active
10819            .iter()
10820            .flat_map(|outcome| outcome.results.iter())
10821            .filter_map(|result| match result {
10822                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
10823                _ => None,
10824            })
10825            .collect();
10826        self.rejections = active
10827            .iter()
10828            .flat_map(|outcome| outcome.results.iter())
10829            .filter_map(|result| match result {
10830                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
10831                _ => None,
10832            })
10833            .collect();
10834
10835        self.lease_state = self
10836            .get_meta(LEASE_STATE_KEY)
10837            .map(|raw| serde_json::from_str(&raw))
10838            .transpose()
10839            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
10840        self.schema_floor = self
10841            .get_meta(SCHEMA_FLOOR_KEY)
10842            .map(|raw| serde_json::from_str(&raw))
10843            .transpose()
10844            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
10845        self.stopped = self.schema_floor.is_some();
10846        // A replica left in unactivated preflight reopens gated: the quarantine
10847        // decision persists with the data across handle teardown and restart.
10848        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
10849            self.security_preflight = true;
10850        }
10851        Ok(())
10852    }
10853
10854    fn save_subscription_scope_schema(&self) {
10855        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
10856            .schema
10857            .tables
10858            .iter()
10859            .map(|table| {
10860                (
10861                    table.name.clone(),
10862                    table
10863                        .scope_variables
10864                        .iter()
10865                        .map(|scope| {
10866                            (
10867                                scope.variable.clone(),
10868                                [scope.prefix.clone(), scope.column.clone()],
10869                            )
10870                        })
10871                        .collect(),
10872                )
10873            })
10874            .collect();
10875        self.set_meta(
10876            "subscriptionScopeSchema",
10877            &serde_json::to_string(&declarations).expect("scope schema serialization"),
10878        );
10879    }
10880
10881    /// Remove registrations with incompatible table/key/prefix/column meaning.
10882    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
10883        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
10884            self.get_meta("subscriptionScopeSchema")
10885                .map(|raw| serde_json::from_str(&raw))
10886                .transpose()
10887                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
10888        } else {
10889            None
10890        };
10891        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
10892            let Some(table) = self.schema.table(table_name) else {
10893                return false;
10894            };
10895            if requested
10896                .iter()
10897                .any(|(variable, _)| table.scope_column(variable).is_none())
10898            {
10899                return false;
10900            }
10901            if !bump {
10902                return true;
10903            }
10904            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
10905                return false;
10906            };
10907            let variables: BTreeSet<_> = if requested.is_empty() {
10908                old.keys()
10909                    .cloned()
10910                    .chain(
10911                        table
10912                            .scope_variables
10913                            .iter()
10914                            .map(|scope| scope.variable.clone()),
10915                    )
10916                    .collect()
10917            } else {
10918                requested
10919                    .iter()
10920                    .map(|(variable, _)| variable.clone())
10921                    .collect()
10922            };
10923            variables.iter().all(|variable| {
10924                let current = table
10925                    .scope_variables
10926                    .iter()
10927                    .find(|scope| &scope.variable == variable);
10928                match (old.get(variable), current) {
10929                    (Some(old), Some(current)) => {
10930                        old == &[current.prefix.clone(), current.column.clone()]
10931                    }
10932                    _ => false,
10933                }
10934            })
10935        };
10936        let mut stale_ids: BTreeSet<_> = self
10937            .subs
10938            .iter()
10939            .filter(|sub| !compatible(&sub.table, &sub.requested))
10940            .map(|sub| sub.id.clone())
10941            .collect();
10942        for (id, table, effective) in self.load_pending_evictions()? {
10943            if !compatible(&table, &effective) {
10944                stale_ids.insert(id);
10945            }
10946        }
10947        for id in &stale_ids {
10948            for sql in [
10949                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
10950                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
10951                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
10952            ] {
10953                self.conn
10954                    .execute(sql, rusqlite::params![id])
10955                    .map_err(|error| error.to_string())?;
10956            }
10957        }
10958        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
10959        Ok(())
10960    }
10961
10962    #[must_use]
10963    pub fn local_revision(&self) -> u64 {
10964        self.get_meta(LOCAL_REVISION_KEY)
10965            .and_then(|value| value.parse().ok())
10966            .unwrap_or(0)
10967    }
10968
10969    /// §7.5: resolve a snapshot read on the mutable owner connection through
10970    /// the same primitive the read-only sidecar uses, so the command surface
10971    /// and the file sidecar return one revision, rows, coverage, catch-up, and
10972    /// delivery shape. The structured [`QueryReadFailure`] is preserved; an
10973    /// `owner` records its failure (and its next success) in diagnostics.
10974    pub fn snapshot_read(
10975        &mut self,
10976        request: &SnapshotReadRequest<'_>,
10977        owner: Option<QueryOwner<'_>>,
10978    ) -> Result<SnapshotRead, QueryReadFailure> {
10979        let result = snapshot_read_connection(&self.conn, request);
10980        if let Some(owner) = owner {
10981            self.record_query_read(owner, result.as_ref().err());
10982        }
10983        result
10984    }
10985
10986    #[must_use]
10987    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
10988        SyncStatusSnapshot {
10989            current_schema_version: self.schema.version,
10990            outbox: self.outbox.len(),
10991            upgrading: self.upgrading,
10992            lease_state: self.lease_state.clone(),
10993            schema_floor: self.schema_floor.clone(),
10994            sync_needed: self.sync_needed,
10995            previous_version_context: self.previous_version_status(),
10996        }
10997    }
10998
10999    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
11000    /// rollback precondition), read without creating the file.
11001    fn previous_version_status(&self) -> PreviousVersionStatus {
11002        let Some(replica_path) = self.previous_version_replica_path() else {
11003            return PreviousVersionStatus {
11004                present: false,
11005                created_at_ms: None,
11006            };
11007        };
11008        match crate::previous_version::read_previous_version_record(&replica_path) {
11009            Ok(Some(record)) => PreviousVersionStatus {
11010                present: true,
11011                created_at_ms: Some(record.created_at_ms),
11012            },
11013            _ => PreviousVersionStatus {
11014                present: std::path::Path::new(
11015                    &crate::previous_version::previous_version_container_path(&replica_path),
11016                )
11017                .exists(),
11018                created_at_ms: None,
11019            },
11020        }
11021    }
11022
11023    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
11024    /// is always `previousVersion`; the container never makes
11025    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
11026    /// revocation, coverage completion and the TTL DISCARD the container and
11027    /// close the read with their own reason.
11028    pub fn previous_version_snapshot(
11029        &self,
11030        spec: &PreviousVersionReadSpec,
11031    ) -> Result<PreviousVersionSnapshot, String> {
11032        let now_ms = self.clock_now_ms();
11033        crate::previous_version::previous_version_snapshot(
11034            &self.conn,
11035            self.previous_version_replica_path().as_deref(),
11036            self.previous_version.as_ref(),
11037            PreviousVersionLifecycle {
11038                lease_inactive: self.previous_version_lease_inactive(now_ms),
11039                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
11040                coverage_complete: self.previous_version_coverage_complete(),
11041            },
11042            now_ms,
11043            self.schema.version,
11044            spec,
11045        )
11046    }
11047
11048    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
11049    #[must_use]
11050    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
11051        crate::previous_version::stored_previous_version_audit(&self.conn)
11052    }
11053
11054    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
11055    /// and both metadata records, and report whether anything was present.
11056    /// Idempotent: absence is a successful no-op.
11057    pub fn previous_version_discard(
11058        &self,
11059    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
11060        self.drop_previous_version()
11061    }
11062
11063    /// The module's discard has no failure path (it removes the file even when
11064    /// the container cannot be opened), so the only error a caller could ever
11065    /// see is a bug; report absence rather than claiming a discard happened.
11066    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
11067        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
11068            present: false,
11069            discarded: false,
11070        };
11071        let Some(replica_path) = self.previous_version_replica_path() else {
11072            return absent;
11073        };
11074        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
11075            .unwrap_or(absent)
11076    }
11077
11078    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
11079    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
11080        let Some(lease) = &self.lease_state else {
11081            return false;
11082        };
11083        if lease.error_code.is_some() {
11084            return true;
11085        }
11086        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
11087    }
11088
11089    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
11090    /// has `cursor >= 0` and no resume token. An empty active set is not
11091    /// completion (mirrors the TS predicate exactly).
11092    fn previous_version_coverage_complete(&self) -> bool {
11093        let active = self
11094            .subs
11095            .iter()
11096            .filter(|sub| sub.state == SubState::Active)
11097            .collect::<Vec<_>>();
11098        if active.is_empty() {
11099            return false;
11100        }
11101        active
11102            .iter()
11103            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
11104    }
11105
11106    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
11107    /// apply. A container that no longer has a reason to exist is discarded so
11108    /// the rows do not outlive the window they were kept for.
11109    fn previous_version_lifetime_check(&self) {
11110        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
11111            return;
11112        };
11113        let now_ms = self.clock_now_ms();
11114        if self.previous_version_lease_inactive(now_ms) {
11115            let _ = self.drop_previous_version();
11116            return;
11117        }
11118        let Some(replica_path) = self.previous_version_replica_path() else {
11119            return;
11120        };
11121        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
11122        else {
11123            return;
11124        };
11125        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
11126            let _ = self.drop_previous_version();
11127            return;
11128        }
11129        if now_ms - record.created_at_ms > config.max_age_ms {
11130            let _ = self.drop_previous_version();
11131            return;
11132        }
11133        if self.previous_version_coverage_complete() {
11134            let _ = self.drop_previous_version();
11135        }
11136    }
11137
11138    pub fn diagnostics_snapshot(
11139        &self,
11140        request: &ClientDiagnosticsRequest,
11141    ) -> Result<ClientDiagnosticsSnapshot, String> {
11142        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
11143            return Err(format!(
11144                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
11145            ));
11146        }
11147        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
11148        for sub in &self.subs {
11149            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
11150            let complete =
11151                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
11152            let state = match sub.state {
11153                SubState::Revoked => "revoked",
11154                SubState::Failed => "failed",
11155                SubState::Active if reset => "reset",
11156                SubState::Active if complete => "complete",
11157                SubState::Active => "bootstrapping",
11158            };
11159            subscriptions.insert(
11160                sub.id.clone(),
11161                DiagnosticSubscription {
11162                    id: sub.id.clone(),
11163                    table: sub.table.clone(),
11164                    state: state.to_owned(),
11165                    complete,
11166                    cursor: Some(sub.cursor),
11167                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
11168                },
11169            );
11170        }
11171        for expected in &request.expected_subscriptions {
11172            if expected.id.is_empty() || expected.table.is_empty() {
11173                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
11174            }
11175            if subscriptions
11176                .get(&expected.id)
11177                .is_some_and(|registered| registered.table != expected.table)
11178            {
11179                subscriptions.insert(
11180                    expected.id.clone(),
11181                    DiagnosticSubscription {
11182                        id: expected.id.clone(),
11183                        table: expected.table.clone(),
11184                        state: "failed".to_owned(),
11185                        complete: false,
11186                        cursor: None,
11187                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
11188                    },
11189                );
11190            } else {
11191                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
11192                    DiagnosticSubscription {
11193                        id: expected.id.clone(),
11194                        table: expected.table.clone(),
11195                        state: "unregistered".to_owned(),
11196                        complete: false,
11197                        cursor: None,
11198                        reason_code: None,
11199                    }
11200                });
11201            }
11202        }
11203        let mut ordered_subscriptions = Vec::new();
11204        let mut included = BTreeSet::new();
11205        for expected in &request.expected_subscriptions {
11206            if included.insert(expected.id.clone()) {
11207                if let Some(subscription) = subscriptions.get(&expected.id) {
11208                    ordered_subscriptions.push(subscription.clone());
11209                }
11210            }
11211        }
11212        for (id, subscription) in &subscriptions {
11213            if included.insert(id.clone()) {
11214                ordered_subscriptions.push(subscription.clone());
11215            }
11216        }
11217        let subscriptions_truncated =
11218            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
11219        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
11220        let captured_at_ms = self.clock_now_ms();
11221        let lease = if let Some(error_code) = self
11222            .lease_state
11223            .as_ref()
11224            .and_then(|state| state.error_code.clone())
11225        {
11226            ClientDiagnosticsLease {
11227                state: "stopped".to_owned(),
11228                expires_at_ms: self
11229                    .lease_state
11230                    .as_ref()
11231                    .and_then(|state| state.expires_at_ms),
11232                error_code: Some(Self::diagnostic_code(&error_code)),
11233            }
11234        } else if let Some(expires_at_ms) = self
11235            .lease_state
11236            .as_ref()
11237            .and_then(|state| state.expires_at_ms)
11238        {
11239            ClientDiagnosticsLease {
11240                state: if expires_at_ms <= captured_at_ms {
11241                    "expired".to_owned()
11242                } else {
11243                    "active".to_owned()
11244                },
11245                expires_at_ms: Some(expires_at_ms),
11246                error_code: None,
11247            }
11248        } else {
11249            ClientDiagnosticsLease {
11250                state: "none".to_owned(),
11251                expires_at_ms: None,
11252                error_code: None,
11253            }
11254        };
11255        let connectivity = match self.last_round.as_ref() {
11256            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
11257            Some(round) if round.status == "succeeded" => "online",
11258            Some(round)
11259                if round.status == "failed"
11260                    && round
11261                        .error_code
11262                        .as_deref()
11263                        .is_some_and(Self::retryable_transport_code) =>
11264            {
11265                "offline"
11266            }
11267            _ => "unknown",
11268        };
11269        Ok(ClientDiagnosticsSnapshot {
11270            version: CLIENT_DIAGNOSTICS_VERSION,
11271            captured_at_ms,
11272            host: ClientDiagnosticsHost {
11273                kind: "direct".to_owned(),
11274                role: "single".to_owned(),
11275                connectivity: connectivity.to_owned(),
11276                realtime: self.realtime_state,
11277                realtime_policy: self.realtime_policy,
11278                realtime_reason_code: self.realtime_reason_code.clone(),
11279                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
11280            },
11281            security_lifecycle: self.security_lifecycle().to_owned(),
11282            schema: ClientDiagnosticsSchema {
11283                current_version: self.schema.version,
11284                upgrading: self.upgrading,
11285                required_version: self
11286                    .schema_floor
11287                    .as_ref()
11288                    .and_then(|floor| floor.required_schema_version),
11289                latest_version: self
11290                    .schema_floor
11291                    .as_ref()
11292                    .and_then(|floor| floor.latest_schema_version),
11293            },
11294            replica: ClientDiagnosticsReplica {
11295                local_revision: self.local_revision().to_string(),
11296                sync_needed: self.sync_needed,
11297                pending_outbox: self.outbox.len(),
11298            },
11299            lease,
11300            subscriptions: ordered_subscriptions,
11301            subscriptions_truncated,
11302            last_round: self.last_round.clone(),
11303            last_change: self.last_change.clone(),
11304            storage: self.diagnostics_storage(),
11305            query_failures: self.query_failures.clone(),
11306        })
11307    }
11308
11309    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
11310        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
11311            let page_count: i64 = self
11312                .conn
11313                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
11314            let page_size: i64 = self
11315                .conn
11316                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
11317            // Reuse compiled aggregates; every invocation still reads current storage.
11318            let outbox_bytes: i64 = self
11319                .conn
11320                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
11321                .query_row([], |row| row.get(0))?;
11322            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
11323                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
11324            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
11325            let blob_bytes = if self.schema_has_blobs() {
11326                self.conn
11327                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
11328                    .query_row([], |row| row.get(0))?
11329            } else {
11330                0
11331            };
11332            let pressure = self
11333                .limits
11334                .blob_cache_max_bytes
11335                .is_some_and(|limit| blob_bytes > limit);
11336            Ok(ClientDiagnosticsStorage {
11337                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
11338                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
11339                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
11340                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
11341                retained_outcome_entries: Some(outcome_entries.max(0)),
11342                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
11343                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
11344            })
11345        };
11346        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
11347            status: "unreadable".to_owned(),
11348            database_bytes_approx: None,
11349            pending_outbox_bytes_approx: None,
11350            retained_outcome_bytes_approx: None,
11351            retained_outcome_entries: None,
11352            blob_cache_bytes_approx: None,
11353            pressure_reason_code: None,
11354        })
11355    }
11356
11357    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
11358        self.change_queue.drain(..).collect()
11359    }
11360
11361    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
11362        let intents = self.sync_intent_queue.drain(..).collect();
11363        if self.transport_enabled {
11364            intents
11365        } else {
11366            vec![SyncIntent::None]
11367        }
11368    }
11369
11370    fn schedule_background_retry(&mut self) -> u64 {
11371        let delay_ms = self.retry_delay_ms;
11372        self.sync_intent_queue
11373            .push_back(SyncIntent::Background { delay_ms });
11374        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
11375        self.round_retry_delay_ms = Some(delay_ms);
11376        delay_ms
11377    }
11378
11379    fn reset_background_retry(&mut self) {
11380        self.retry_delay_ms = 250;
11381    }
11382
11383    fn retryable_transport_code(code: &str) -> bool {
11384        code == "transport.failed"
11385            || code == "transport.unavailable"
11386            || code == "sync.transport_failed"
11387    }
11388
11389    /// A failed round worth a background retry: a transport failure, or a
11390    /// §10.2 code the catalog marks retryable (clients may hardcode the
11391    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
11392    /// on the server's `retryable` flag.
11393    fn retryable_failure_code(code: &str) -> bool {
11394        Self::retryable_transport_code(code)
11395            || matches!(
11396                code,
11397                "sync.auth_required"
11398                    | "sync.auth_lease_required"
11399                    | "sync.auth_lease_revoked"
11400                    | "sync.internal_error"
11401                    | "sync.idempotency_cache_miss"
11402                    | "sync.schema_not_ready"
11403                    | "sync.segment_expired"
11404                    | "sync.rate_limited"
11405                    | "sync.websocket_connection_limit"
11406            )
11407    }
11408
11409    fn diagnostic_code(code: &str) -> String {
11410        let valid = !code.is_empty()
11411            && code.len() <= 96
11412            && code.contains('.')
11413            && code
11414                .bytes()
11415                .next()
11416                .is_some_and(|byte| byte.is_ascii_lowercase())
11417            && code.bytes().all(|byte| {
11418                byte.is_ascii_lowercase()
11419                    || byte.is_ascii_digit()
11420                    || matches!(byte, b'.' | b'_' | b'-')
11421            });
11422        if valid {
11423            code.to_owned()
11424        } else {
11425            "client.unknown_failure".to_owned()
11426        }
11427    }
11428
11429    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
11430        if self.sync_needed != value {
11431            if self.begin_observation("syncular_status").is_ok() {
11432                self.sync_needed = value;
11433                let batch = ChangeAccumulator {
11434                    status: true,
11435                    ..ChangeAccumulator::default()
11436                };
11437                if self.finish_observation("syncular_status", batch).is_err() {
11438                    self.rollback_observation("syncular_status");
11439                }
11440            } else {
11441                self.sync_needed = value;
11442            }
11443        }
11444        if value && interactive {
11445            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11446        }
11447    }
11448
11449    fn sqlite_failure(
11450        storage_failure: &RefCell<Option<QueryReadFailure>>,
11451        error: rusqlite::Error,
11452    ) -> String {
11453        let message = error.to_string();
11454        let failure = QueryReadFailure::from(error);
11455        let code = failure.code;
11456        if code.is_some() && storage_failure.borrow().is_none() {
11457            *storage_failure.borrow_mut() = Some(failure);
11458        }
11459        match code {
11460            Some(code) => format!("{code}: {message}"),
11461            None => message,
11462        }
11463    }
11464
11465    fn begin_observation(&self, name: &str) -> Result<(), String> {
11466        self.conn
11467            .execute_batch(&format!("SAVEPOINT {name}"))
11468            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11469    }
11470
11471    fn rollback_observation(&self, name: &str) {
11472        if let Err(error) = self
11473            .conn
11474            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
11475        {
11476            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
11477                if failure.rollback_failure.is_none() {
11478                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
11479                }
11480            }
11481        }
11482    }
11483
11484    fn finish_observation(
11485        &mut self,
11486        name: &str,
11487        mut batch: ChangeAccumulator,
11488    ) -> Result<(), String> {
11489        #[cfg(feature = "bench-internals")]
11490        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
11491        if self.failed_commits.iter().any(|(commit, _)| {
11492            commit
11493                .ops
11494                .iter()
11495                .any(|operation| batch.tables.contains_key(&operation.table))
11496        }) {
11497            batch.outcomes = true;
11498        }
11499        if !batch.touched() {
11500            self.conn
11501                .execute_batch(&format!("RELEASE {name}"))
11502                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11503            return Ok(());
11504        }
11505        let revision = self
11506            .local_revision()
11507            .checked_add(1)
11508            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
11509        self.conn
11510            .execute(
11511                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11512                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
11513            )
11514            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11515        let status = batch.status.then(|| self.status_snapshot());
11516        let event = ClientChangeBatch {
11517            revision: revision.to_string(),
11518            tables: batch
11519                .tables
11520                .into_iter()
11521                .map(|(table, scope_keys)| TableChange {
11522                    table,
11523                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
11524                })
11525                .collect(),
11526            windows: batch
11527                .windows
11528                .into_iter()
11529                .map(|((base_key, table), units)| WindowChange {
11530                    base_key,
11531                    table,
11532                    units: units.into_iter().collect(),
11533                })
11534                .collect(),
11535            status,
11536            conflicts_changed: batch.conflicts,
11537            rejections_changed: batch.rejections,
11538            outcomes_changed: batch.outcomes,
11539        };
11540        self.conn
11541            .execute_batch(&format!("RELEASE {name}"))
11542            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11543        let mut diagnostic_tables = event
11544            .tables
11545            .iter()
11546            .map(|entry| entry.table.clone())
11547            .collect::<BTreeSet<_>>()
11548            .into_iter()
11549            .collect::<Vec<_>>();
11550        let mut diagnostic_windows = event
11551            .windows
11552            .iter()
11553            .map(|entry| entry.table.clone())
11554            .collect::<BTreeSet<_>>()
11555            .into_iter()
11556            .collect::<Vec<_>>();
11557        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
11558            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
11559        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
11560        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
11561        self.last_change = Some(DiagnosticLastChange {
11562            revision: event.revision.clone(),
11563            recorded_at_ms: self.clock_now_ms(),
11564            tables: diagnostic_tables,
11565            windows: diagnostic_windows,
11566            domains_truncated,
11567            status_changed: event.status.is_some(),
11568            conflicts_changed: event.conflicts_changed,
11569            rejections_changed: event.rejections_changed,
11570            outcomes_changed: event.outcomes_changed,
11571        });
11572        self.change_queue.push_back(event);
11573        Ok(())
11574    }
11575
11576    fn record_scope_map(
11577        &self,
11578        batch: &mut ChangeAccumulator,
11579        table_name: &str,
11580        scopes: &[(String, Vec<String>)],
11581    ) {
11582        let Some(table) = self.schema.table(table_name) else {
11583            return;
11584        };
11585        for (variable, values) in scopes {
11586            let Some(scope) = table
11587                .scope_variables
11588                .iter()
11589                .find(|scope| &scope.variable == variable)
11590            else {
11591                continue;
11592            };
11593            for value in values {
11594                batch.scope(table_name, format!("{}:{value}", scope.prefix));
11595            }
11596        }
11597    }
11598
11599    /// Record a row's current scope keys from the base or visible table.
11600    fn record_row_scopes(
11601        &self,
11602        batch: &mut ChangeAccumulator,
11603        table_name: &str,
11604        row_id: &str,
11605        base: bool,
11606    ) -> bool {
11607        let Some(table) = self.schema.table(table_name) else {
11608            return false;
11609        };
11610        if table.scope_variables.is_empty() {
11611            return false;
11612        }
11613        let columns = table
11614            .scope_variables
11615            .iter()
11616            .map(|scope| quote_ident(&scope.column))
11617            .collect::<Vec<_>>()
11618            .join(", ");
11619        let full_table = if base {
11620            base_table(table_name)
11621        } else {
11622            visible_table(table_name)
11623        };
11624        let sql = format!(
11625            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
11626            row_id_predicate(table)
11627        );
11628        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
11629            return false;
11630        };
11631        let values = stmt.query_row(rusqlite::params![row_id], |row| {
11632            let mut values = Vec::with_capacity(table.scope_variables.len());
11633            for index in 0..table.scope_variables.len() {
11634                values.push(row.get::<_, Option<String>>(index)?);
11635            }
11636            Ok(values)
11637        });
11638        let Ok(values) = values else {
11639            return false;
11640        };
11641        let mut recorded = false;
11642        for (scope, value) in table.scope_variables.iter().zip(values) {
11643            if let Some(value) = value {
11644                batch.scope(table_name, format!("{}:{value}", scope.prefix));
11645                recorded = true;
11646            }
11647        }
11648        recorded
11649    }
11650
11651    fn record_commit_changes(
11652        &self,
11653        batch: &mut ChangeAccumulator,
11654        tables: &[String],
11655        changes: &[ssp2::model::Change],
11656    ) {
11657        #[cfg(feature = "bench-internals")]
11658        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
11659        for change in changes {
11660            let Some(table_name) = tables.get(change.table_index as usize) else {
11661                continue;
11662            };
11663            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
11664            if let Some(table) = self.schema.table(table_name) {
11665                for (variable, value) in &change.scopes {
11666                    if let Some(scope) = table
11667                        .scope_variables
11668                        .iter()
11669                        .find(|scope| &scope.variable == variable)
11670                    {
11671                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
11672                        precise = true;
11673                    }
11674                }
11675            }
11676            if !precise {
11677                batch.table(table_name);
11678            }
11679        }
11680    }
11681
11682    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
11683        if effective.is_empty() {
11684            return false;
11685        }
11686        let Some(table) = self.schema.table(table_name) else {
11687            return false;
11688        };
11689        let mut clauses = Vec::new();
11690        let mut params = Vec::new();
11691        for (variable, values) in effective {
11692            let Some(column) = table.scope_column(variable) else {
11693                return false;
11694            };
11695            if values.is_empty() {
11696                return false;
11697            }
11698            let placeholders = values
11699                .iter()
11700                .map(|value| {
11701                    params.push(SqlValue::Text(value.clone()));
11702                    "?"
11703                })
11704                .collect::<Vec<_>>()
11705                .join(", ");
11706            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
11707        }
11708        let sql = format!(
11709            "SELECT 1 FROM {} WHERE {} LIMIT 1",
11710            base_table(table_name),
11711            clauses.join(" AND ")
11712        );
11713        self.conn
11714            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
11715            .is_ok()
11716    }
11717
11718    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
11719    pub fn upgrading(&self) -> bool {
11720        self.upgrading
11721    }
11722
11723    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
11724    /// keeping this client's local database (identity, outbox, tables). The
11725    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
11726    /// version increases. Recreation runs the same marker guard as opening
11727    /// a durable replica, holds the guard and every write it authorizes under
11728    /// one write transaction, and preserves the current client (compiled
11729    /// schema, outbox, tables) on refusal.
11730    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
11731        let new_schema = parse_schema_json(schema_json)?;
11732        validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
11733        self.conn
11734            .execute_batch("BEGIN IMMEDIATE")
11735            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11736        let prior_schema = std::mem::replace(&mut self.schema, new_schema);
11737        // §7.4.3/§7.4.4: a reset inside this transaction mutates in-memory
11738        // state (dropped outbox commits, rejections, upgrade and floor flags,
11739        // overlay dirtiness) and appends change/intent batches. A COMMIT that
11740        // fails after the reset succeeded rolls the database back, so every
11741        // one of those fields is captured here and restored with it.
11742        let prior_subs = self.subs.clone();
11743        let prior_outbox = self.outbox.clone();
11744        let prior_rejections = self.rejections.clone();
11745        let prior_upgrading = self.upgrading;
11746        let prior_stopped = self.stopped;
11747        let prior_schema_floor = self.schema_floor.clone();
11748        let prior_overlay_dirty = self.overlay_dirty.snapshot();
11749        let prior_sync_needed = self.sync_needed;
11750        let prior_active_round = self.active_round;
11751        let prior_changes = self.change_queue.len();
11752        let prior_intents = self.sync_intent_queue.len();
11753        let prior_last_change = self.last_change.clone();
11754        let guarded = (|| -> Result<(), String> {
11755            // §7.4.2: the marker is re-read under the write lock the caller
11756            // holds, so a replica another process upgraded meanwhile refuses
11757            // this recreation instead of having its marker and tables
11758            // downgraded.
11759            let marker = read_local_schema_version(&self.conn, self.schema.version)?;
11760            self.create_bookkeeping_tables()?;
11761            match marker {
11762                Some(version) if version < self.schema.version => self.run_schema_reset()?,
11763                None => {
11764                    self.create_synced_tables()?;
11765                    self.persist_schema_version()?;
11766                }
11767                _ => {}
11768            }
11769            // §7.4.2: recreation IS the boot for the in-memory core, so reload the
11770            // durable outbox the same-version path is about to replay. Only the
11771            // outbox is reloaded: the recovery below reads it, and the caller's
11772            // rollback snapshot already covers this field.
11773            self.outbox = self.load_outbox()?;
11774            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
11775            // this guarded boundary once the schema and marker agree. A bump went
11776            // through `run_schema_reset`, which performs the same recovery after
11777            // it creates the new physical tables and before its final replay.
11778            self.drop_unencodable_outbox(false, None)?;
11779            if marker == Some(self.schema.version)
11780                && (!self.outbox.is_empty()
11781                    || !self.failed_commits.is_empty()
11782                    || self.has_acknowledged_rows())
11783            {
11784                self.overlay_dirty.set(true);
11785                self.rebuild_overlay()?;
11786            }
11787            self.prune_unknown_subscriptions(false)?;
11788            self.save_subscription_scope_schema();
11789            // The conformance recreate is the in-memory equivalent of reopening
11790            // a durable client. Apply the same startup catch-up contract even
11791            // when the schema itself did not change.
11792            self.enqueue_startup_sync_if_needed()?;
11793            Ok(())
11794        })();
11795        let outcome = match guarded {
11796            Ok(()) => self
11797                .conn
11798                .execute_batch("COMMIT")
11799                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
11800            Err(error) => Err(error),
11801        };
11802        if let Err(error) = outcome {
11803            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
11804                // Keep a failed rollback's typed storage classification.
11805                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
11806            }
11807            self.schema = prior_schema;
11808            self.subs = prior_subs;
11809            self.outbox = prior_outbox;
11810            self.rejections = prior_rejections;
11811            self.upgrading = prior_upgrading;
11812            self.stopped = prior_stopped;
11813            self.schema_floor = prior_schema_floor;
11814            self.overlay_dirty.restore(prior_overlay_dirty);
11815            self.sync_needed = prior_sync_needed;
11816            self.active_round = prior_active_round;
11817            self.change_queue.truncate(prior_changes);
11818            self.sync_intent_queue.truncate(prior_intents);
11819            self.last_change = prior_last_change;
11820            // Prepared insert statements were derived from the reverted schema.
11821            self.insert_sql.borrow_mut().clear();
11822            return Err(error);
11823        }
11824        Ok(())
11825    }
11826
11827    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
11828        let startup_work = !self.stopped
11829            && (!self.outbox.is_empty()
11830                || self.has_acknowledged_rows()
11831                || !self.load_pending_evictions()?.is_empty()
11832                || self.subs.iter().any(|sub| sub.state == SubState::Active));
11833        if startup_work {
11834            self.sync_needed = true;
11835            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11836        }
11837        Ok(())
11838    }
11839
11840    /// A native client persists schema-floor stops across process restarts.
11841    /// Once the running app already satisfies that floor, the persisted stop
11842    /// is only stale evidence from an older server round (for example, the
11843    /// server was restarted after catching up to an app that was ahead).
11844    /// Clear it and let the normal startup pull re-negotiate. If the server is
11845    /// still incompatible it will return the floor again in that first round.
11846    fn clear_satisfied_persisted_schema_floor(&mut self) {
11847        let satisfied = self
11848            .schema_floor
11849            .as_ref()
11850            .and_then(|floor| floor.required_schema_version)
11851            .is_some_and(|required| self.schema.version >= required);
11852        if !satisfied {
11853            return;
11854        }
11855        self.schema_floor = None;
11856        self.stopped = false;
11857        self.delete_meta(SCHEMA_FLOOR_KEY);
11858    }
11859
11860    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
11861    /// path already swept it; this covers a same-version or fresh-install open,
11862    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
11863    /// an aware binary can apply.
11864    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
11865        let Some(replica_path) = self.previous_version_replica_path() else {
11866            return Ok(());
11867        };
11868        let max_age_ms = self
11869            .previous_version
11870            .filter(|config| config.enabled)
11871            .map(|config| config.max_age_ms);
11872        reconcile_previous_version_at_boot(
11873            &self.conn,
11874            &replica_path,
11875            self.schema.version,
11876            max_age_ms,
11877            self.clock_now_ms(),
11878        )
11879    }
11880
11881    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
11882    /// no file — an in-memory replica returns an empty path, and a `file:` URI
11883    /// has no plain sibling path — so the feature resolves as off there.
11884    fn previous_version_replica_path(&self) -> Option<String> {
11885        let path = self.conn.path()?;
11886        if path.is_empty() || path.starts_with("file:") {
11887            return None;
11888        }
11889        Some(path.to_owned())
11890    }
11891
11892    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
11893    /// and leaseState. Drops/recreates every synced table from the new
11894    /// schema, resets subscription sync-state (keeping registrations), clears
11895    /// the schema-floor stop state, rewrites the marker, drops outbox commits
11896    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
11897    fn run_schema_reset(&mut self) -> Result<(), String> {
11898        self.begin_observation("syncular_schema_reset")?;
11899        let prior_subs = self.subs.clone();
11900        let prior_outbox = self.outbox.clone();
11901        let prior_rejections = self.rejections.clone();
11902        let prior_upgrading = self.upgrading;
11903        let prior_stopped = self.stopped;
11904        let prior_schema_floor = self.schema_floor.clone();
11905        let prior_overlay_dirty = self.overlay_dirty.snapshot();
11906        let prior_sync_needed = self.sync_needed;
11907        let prior_active_round = self.active_round;
11908        let mut batch = ChangeAccumulator::default();
11909        let result = self
11910            .run_schema_reset_observed(&mut batch, true)
11911            .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
11912        if let Err(error) = result {
11913            self.rollback_observation("syncular_schema_reset");
11914            self.subs = prior_subs;
11915            self.outbox = prior_outbox;
11916            self.rejections = prior_rejections;
11917            self.upgrading = prior_upgrading;
11918            self.stopped = prior_stopped;
11919            self.schema_floor = prior_schema_floor;
11920            self.overlay_dirty.restore(prior_overlay_dirty);
11921            self.sync_needed = prior_sync_needed;
11922            self.active_round = prior_active_round;
11923            return Err(error);
11924        }
11925        Ok(())
11926    }
11927
11928    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
11929        if self.get_meta(LOG_EPOCH_KEY).is_none() {
11930            self.begin_observation("syncular_epoch_acquisition")?;
11931            let previous_sync_needed = self.sync_needed;
11932            if let Err(error) = self.conn.execute(
11933                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11934                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
11935            ) {
11936                self.rollback_observation("syncular_epoch_acquisition");
11937                return Err(Self::sqlite_failure(&self.storage_failure, error));
11938            }
11939            self.sync_needed = true;
11940            let batch = ChangeAccumulator {
11941                status: true,
11942                ..ChangeAccumulator::default()
11943            };
11944            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
11945                self.rollback_observation("syncular_epoch_acquisition");
11946                self.sync_needed = previous_sync_needed;
11947                return Err(error);
11948            }
11949            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11950            return Ok(Vec::new());
11951        }
11952        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
11953        self.begin_observation("syncular_log_epoch_reset")?;
11954        let prior_subs = self.subs.clone();
11955        let prior_outbox = self.outbox.clone();
11956        let prior_rejections = self.rejections.clone();
11957        let prior_upgrading = self.upgrading;
11958        let prior_stopped = self.stopped;
11959        let prior_schema_floor = self.schema_floor.clone();
11960        let prior_overlay_dirty = self.overlay_dirty.snapshot();
11961        let prior_sync_needed = self.sync_needed;
11962        let prior_active_round = self.active_round;
11963        let mut batch = ChangeAccumulator::default();
11964        let result = self
11965            .run_schema_reset_observed(&mut batch, false)
11966            .and_then(|()| {
11967                self.conn
11968                    .execute(
11969                        "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11970                        rusqlite::params![LOG_EPOCH_KEY, log_epoch],
11971                    )
11972                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11973                self.sync_needed = true;
11974                batch.status = true;
11975                self.finish_observation("syncular_log_epoch_reset", batch)
11976            });
11977        if let Err(error) = result {
11978            self.rollback_observation("syncular_log_epoch_reset");
11979            self.subs = prior_subs;
11980            self.outbox = prior_outbox;
11981            self.rejections = prior_rejections;
11982            self.upgrading = prior_upgrading;
11983            self.stopped = prior_stopped;
11984            self.schema_floor = prior_schema_floor;
11985            self.overlay_dirty.restore(prior_overlay_dirty);
11986            self.sync_needed = prior_sync_needed;
11987            self.active_round = prior_active_round;
11988            return Err(error);
11989        }
11990        self.sync_intent_queue.push_back(SyncIntent::Interactive);
11991        Ok(resets)
11992    }
11993
11994    fn run_schema_reset_observed(
11995        &mut self,
11996        batch: &mut ChangeAccumulator,
11997        capture: bool,
11998    ) -> Result<(), String> {
11999        // §7.4.2: the reset is the only path that wipes local tables for a
12000        // version bump, and it rewrites the marker last. Re-read the marker
12001        // under this reset's transaction: a replica another process upgraded
12002        // meanwhile refuses here instead of being reset to this client's
12003        // older schema.
12004        read_local_schema_version(&self.conn, self.schema.version)?;
12005        self.conn
12006            .execute_batch(
12007                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
12008            )
12009            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12010        self.cancel_sync_round();
12011        self.upgrading = true;
12012        batch.status = true;
12013        // RFC 0005 D6: classify the pending outbox against the NEW compiled
12014        // schema BEFORE the wipe, and record the advisory audit. This pass
12015        // drops nothing, so the audit sees the original pending intent; the
12016        // §7.4.4 send-time drop and the intrinsic value recovery later in this
12017        // reset are the paths that remove a commit. The classification is
12018        // shared with the send-time drop so the two cannot disagree about what
12019        // is incompatible.
12020        if self.previous_version.is_some_and(|config| config.enabled) {
12021            let previous_version = self
12022                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12023                .and_then(|value| value.parse::<i32>().ok())
12024                .unwrap_or(self.schema.version);
12025            let pending = self
12026                .outbox
12027                .iter()
12028                .map(|commit| PendingCommitAudit {
12029                    commit_id: &commit.client_commit_id,
12030                    operations: commit_audit_operations(commit),
12031                })
12032                .collect::<Vec<_>>();
12033            crate::previous_version::write_previous_version_audit(
12034                &self.conn,
12035                &crate::previous_version::build_previous_version_audit(
12036                    &self.schema,
12037                    &pending,
12038                    previous_version,
12039                    self.schema.version,
12040                    self.clock_now_ms(),
12041                ),
12042            );
12043        }
12044        // RFC 0005 D5: the container lives in its own FILE, so the reset can
12045        // never reach it. Sweep first (unconditional — also on the log-epoch
12046        // reset, which captures nothing), then capture BEFORE the wipe drops
12047        // any row. A refused capture records its reason and leaves no file.
12048        if let Some(replica_path) = self.previous_version_replica_path() {
12049            sweep_previous_version_container(&self.conn, &replica_path)?;
12050            if capture {
12051                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
12052                    let previous_version = self
12053                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12054                        .and_then(|value| value.parse::<i32>().ok())
12055                        .unwrap_or(self.schema.version);
12056                    capture_previous_version_from_replica(
12057                        &self.conn,
12058                        &replica_path,
12059                        previous_version,
12060                        self.schema.version,
12061                        &config,
12062                        self.clock_now_ms(),
12063                    )?;
12064                }
12065            }
12066        }
12067        for table in &self.schema.tables {
12068            batch.table(&table.name);
12069        }
12070        for (base_key, unit, table) in self.load_registered_window_units() {
12071            batch.window(&base_key, &table, &unit);
12072        }
12073        if capture {
12074            self.prune_unknown_subscriptions(true)?;
12075        }
12076        // The per-table insert SQL is derived from the OLD column lists.
12077        self.insert_sql.borrow_mut().clear();
12078        self.overlay_dirty.set(true);
12079        // Drop every synced table (base + visible) that currently exists —
12080        // discovered from sqlite_master so a bump that adds/removes tables is
12081        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
12082        // and the blob cache) are preserved; base tables are `_syncular_base_*`
12083        // so they are matched explicitly, not by the bookkeeping filter.
12084        // Drop virtual tables first so SQLite removes every FTS shadow table
12085        // atomically instead of the generic discovery tearing it apart.
12086        let virtual_tables: Vec<String> = {
12087            let mut stmt = self
12088                .conn
12089                .prepare(
12090                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
12091                )
12092                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12093            let rows = stmt
12094                .query_map([], |row| row.get::<_, String>(0))
12095                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12096            rows.filter_map(Result::ok)
12097                .filter(|name| is_synced_table_name(name))
12098                .collect()
12099        };
12100        for name in virtual_tables {
12101            self.conn
12102                .execute_batch(&format!(
12103                    "DROP TABLE IF EXISTS {}",
12104                    quote_ident(&format!("_syncular_fts_{name}"))
12105                ))
12106                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12107            self.conn
12108                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12109                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12110        }
12111        let existing: Vec<String> = {
12112            let mut stmt = self
12113                .conn
12114                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
12115                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12116            let rows = stmt
12117                .query_map([], |row| row.get::<_, String>(0))
12118                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12119            rows.filter_map(Result::ok)
12120                .filter(|name| is_synced_table_name(name))
12121                .collect()
12122        };
12123        for name in &existing {
12124            if let Some(table) = name.strip_prefix("_syncular_base_") {
12125                batch.table(table);
12126            } else if !name.starts_with("_syncular_") {
12127                batch.table(name);
12128            }
12129        }
12130        for name in existing {
12131            self.conn
12132                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12133                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12134        }
12135        // Recreate the synced tables from the NEW schema.
12136        self.create_synced_tables()?;
12137        // Reset every subscription's sync-state, keeping the registration.
12138        for sub in &mut self.subs {
12139            sub.cursor = -1;
12140            sub.bootstrap_state = None;
12141            sub.effective = None;
12142            sub.state = SubState::Active;
12143            sub.reason_code = None;
12144            sub.synced_once = false;
12145        }
12146        let subs = self.subs.clone();
12147        for sub in &subs {
12148            self.persist_sub(sub)?;
12149        }
12150        // The stop state is over: this client now ships a servable schema.
12151        self.stopped = false;
12152        self.schema_floor = None;
12153        self.delete_meta(SCHEMA_FLOOR_KEY);
12154        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
12155        self.persist_schema_version()?;
12156        self.save_subscription_scope_schema();
12157        // §7.1/§7.4.4: the previous-version capture and audit above saw the
12158        // original pending intent; the synced tables now carry the new schema, so
12159        // recover intrinsically codec-invalid persisted values here, before the
12160        // final replay. A pending upsert that names a column the new schema
12161        // removed stays pending for the send-time `drop_incompatible_outbox`
12162        // prepass. The reset wrapper snapshots and restores the outbox,
12163        // rejections, and change queues, so a failed commit rolls this recovery
12164        // back with the rest of the reset.
12165        // §7.4.4: the enclosing reset observation owns the publication, so the
12166        // recovery contributes its rejection, outcome, and table domains to it.
12167        self.drop_unencodable_outbox(false, Some(batch))?;
12168        // Re-apply the surviving outbox optimistically over the empty tables.
12169        self.rebuild_overlay()?;
12170        Ok(())
12171    }
12172
12173    /// §7.4.4 send-time prepass: a persisted upsert whose values reference a
12174    /// column the current schema lacks (or a removed table) cannot be encoded
12175    /// (`commit_audit_operations` inspects upserts, so a value-free delete stays
12176    /// encodable and is validated by the server). Drop the commit, undo its
12177    /// optimistic projection, and raise a client-local
12178    /// `sync.outbox_incompatible` rejection.
12179    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
12180        let incompatible = self
12181            .outbox
12182            .iter()
12183            .filter(|commit| {
12184                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
12185            })
12186            .cloned()
12187            .collect::<Vec<_>>();
12188        if incompatible.is_empty() {
12189            return Ok(false);
12190        }
12191        // §7.4.4: one observation covers the durable outcome writes, the outbox
12192        // removal, the in-memory drop, and the overlay rebuild, so a storage
12193        // fault leaves the replica and the client unchanged and publishes no
12194        // change; a successful drop publishes exactly one batch carrying the
12195        // rejections, outcomes, status, and the tables whose projection changed.
12196        let prior_outbox = self.outbox.clone();
12197        let prior_rejections = self.rejections.clone();
12198        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12199        let owns_transaction = self.conn.is_autocommit();
12200        self.begin_observation("syncular_outbox_drop")?;
12201        let mut batch = ChangeAccumulator::default();
12202        let dropped = (|| -> Result<(), String> {
12203            let mut rejections = Vec::new();
12204            for commit in &incompatible {
12205                let results = commit
12206                    .ops
12207                    .iter()
12208                    .enumerate()
12209                    .map(|(op_index, operation)| {
12210                        let rejection = RejectionRecord {
12211                            client_commit_id: commit.client_commit_id.clone(),
12212                            op_index: op_index as i32,
12213                            code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
12214                            message: "the persisted commit cannot encode under the current schema"
12215                                .to_owned(),
12216                            retryable: false,
12217                            details: None,
12218                            operation: Some(CommitOperation::from(operation)),
12219                        };
12220                        rejections.push(rejection.clone());
12221                        CommitOperationOutcome::Error { rejection }
12222                    })
12223                    .collect::<Vec<_>>();
12224                self.persist_commit_outcome(
12225                    &commit.client_commit_id,
12226                    CommitOutcomeStatus::Rejected,
12227                    &results,
12228                    Some(&commit.ops),
12229                )?;
12230                self.delete_outbox_persisted(&commit.client_commit_id)?;
12231                // A removed table has no local projection to invalidate; every
12232                // surviving table the dropped commit touched does.
12233                for operation in &commit.ops {
12234                    if self.schema.table(&operation.table).is_some() {
12235                        batch.table(&operation.table);
12236                    }
12237                }
12238            }
12239            self.prune_commit_outcomes()?;
12240            let incompatible_ids = incompatible
12241                .iter()
12242                .map(|commit| commit.client_commit_id.as_str())
12243                .collect::<BTreeSet<_>>();
12244            self.outbox
12245                .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
12246            self.rejections.extend(rejections);
12247            // The dropped commits' purely-optimistic rows are undone: the
12248            // visible projection is re-derived from the surviving base + outbox.
12249            self.overlay_dirty.set(true);
12250            self.rebuild_overlay()?;
12251            batch.status = true;
12252            batch.rejections = true;
12253            batch.outcomes = true;
12254            Ok(())
12255        })();
12256        let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
12257        if let Err(error) = result {
12258            // An outer RELEASE is the COMMIT. Roll back that transaction
12259            // directly: retrying RELEASE after ROLLBACK TO still needs the
12260            // reader's lock. A nested observation only rolls back its savepoint.
12261            if owns_transaction {
12262                if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12263                    let mut failure = self.storage_failure.borrow_mut();
12264                    let failure =
12265                        failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
12266                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
12267                }
12268            } else {
12269                self.rollback_observation("syncular_outbox_drop");
12270            }
12271            self.outbox = prior_outbox;
12272            self.rejections = prior_rejections;
12273            self.overlay_dirty.restore(prior_overlay_dirty);
12274            return Err(error);
12275        }
12276        Ok(true)
12277    }
12278
12279    /// §7.4.3: (re)create the base + visible table pair for every synced
12280    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
12281    fn create_synced_tables(&self) -> Result<(), String> {
12282        for table in &self.schema.tables {
12283            // The base half + the visible half form the synced-table pair. An
12284            // index name is global in SQLite, so the base half's indexes are
12285            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
12286            for (full, index_prefix) in [
12287                (base_table(&table.name), "_syncular_base_"),
12288                (visible_table(&table.name), ""),
12289            ] {
12290                let mut cols: Vec<String> =
12291                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
12292                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
12293                let sql = format!(
12294                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
12295                    cols.join(", "),
12296                    quote_ident(&table.primary_key)
12297                );
12298                self.conn
12299                    .execute(&sql, [])
12300                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12301                // Local secondary indexes (CREATE INDEX subset). Created on
12302                // both halves so mirror reads hit an index on either. Runs on
12303                // both the initial create and the §7.4.3 reset recreate path.
12304                for index in &table.indexes {
12305                    let unique = if index.unique { "UNIQUE " } else { "" };
12306                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
12307                    let cols_sql = index
12308                        .columns
12309                        .iter()
12310                        .map(|c| quote_ident(c))
12311                        .collect::<Vec<_>>()
12312                        .join(", ");
12313                    let index_sql = format!(
12314                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
12315                    );
12316                    self.conn
12317                        .execute(&index_sql, [])
12318                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12319                }
12320            }
12321        }
12322        // FTS exists only on the visible half. It is a local search
12323        // projection, never a synced/base table or a wire-schema member.
12324        for table in &self.schema.tables {
12325            for index in &table.fts_indexes {
12326                self.create_fts_projection(table, index)?;
12327            }
12328        }
12329        Ok(())
12330    }
12331
12332    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
12333        let count: i64 = self
12334            .conn
12335            .query_row(
12336                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
12337                rusqlite::params![index.name],
12338                |row| row.get(0),
12339            )
12340            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12341        Ok(count > 0)
12342    }
12343
12344    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
12345        for suffix in ["ai", "ad", "au"] {
12346            self.conn
12347                .execute(
12348                    &format!(
12349                        "DROP TRIGGER IF EXISTS {}",
12350                        quote_ident(&format!("{}_{suffix}", index.name))
12351                    ),
12352                    [],
12353                )
12354                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12355        }
12356        Ok(())
12357    }
12358
12359    fn create_fts_triggers(
12360        &self,
12361        table: &TableSchema,
12362        index: &FtsIndexSchema,
12363    ) -> Result<(), String> {
12364        let fts = quote_ident(&index.name);
12365        let source = visible_table(&table.name);
12366        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12367        let pk = quote_ident(&table.primary_key);
12368        let projection_columns = std::iter::once(source_id.clone())
12369            .chain(index.columns.iter().map(|column| quote_ident(column)))
12370            .collect::<Vec<_>>()
12371            .join(", ");
12372        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
12373            .chain(
12374                index
12375                    .columns
12376                    .iter()
12377                    .map(|column| format!("new.{}", quote_ident(column))),
12378            )
12379            .collect::<Vec<_>>()
12380            .join(", ");
12381        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12382        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)");
12383        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)");
12384        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})");
12385        let bi = quote_ident(&format!("{}_bi", index.name));
12386        let ai = quote_ident(&format!("{}_ai", index.name));
12387        let ad = quote_ident(&format!("{}_ad", index.name));
12388        let au = quote_ident(&format!("{}_au", index.name));
12389        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
12390        let sql = format!(
12391            "DROP TRIGGER IF EXISTS {bi};
12392             DROP TRIGGER IF EXISTS {ai};
12393             DROP TRIGGER IF EXISTS {ad};
12394             DROP TRIGGER IF EXISTS {au};
12395             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
12396             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
12397             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
12398             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
12399        );
12400        self.conn
12401            .execute_batch(&sql)
12402            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12403    }
12404
12405    fn rebuild_fts_projection(
12406        &self,
12407        table: &TableSchema,
12408        index: &FtsIndexSchema,
12409    ) -> Result<(), String> {
12410        let fts = quote_ident(&index.name);
12411        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12412        let pk = quote_ident(&table.primary_key);
12413        let indexed_columns = index
12414            .columns
12415            .iter()
12416            .map(|column| quote_ident(column))
12417            .collect::<Vec<_>>();
12418        let projection_columns = std::iter::once(source_id.clone())
12419            .chain(indexed_columns.iter().cloned())
12420            .collect::<Vec<_>>()
12421            .join(", ");
12422        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12423        let sql = format!(
12424            "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};",
12425            columns = indexed_columns.join(", "),
12426            source = visible_table(&table.name),
12427        );
12428        self.conn
12429            .execute_batch(&sql)
12430            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12431    }
12432
12433    fn create_fts_projection(
12434        &self,
12435        table: &TableSchema,
12436        index: &FtsIndexSchema,
12437    ) -> Result<(), String> {
12438        self.begin_observation("syncular_fts_schema")?;
12439        let result = (|| {
12440            let existed = self.fts_projection_exists(index)?;
12441            let tokenizer = index.tokenize.replace('\'', "''");
12442            let columns = index
12443                .columns
12444                .iter()
12445                .map(|column| quote_ident(column))
12446                .collect::<Vec<_>>()
12447                .join(", ");
12448            let sql = format!(
12449            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
12450            fts = quote_ident(&index.name),
12451            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
12452        );
12453            self.conn.execute(&sql, []).map_err(|error| {
12454                format!(
12455                    "cannot create local FTS5 projection {:?}: {error}",
12456                    index.name
12457                )
12458            })?;
12459            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12460            let mapped: bool = self
12461                .conn
12462                .query_row(
12463                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
12464                    [format!("_syncular_fts_{}", index.name)],
12465                    |row| row.get(0),
12466                )
12467                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12468            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))?;
12469            if !mapped && existed {
12470                self.conn
12471                    .execute_batch(&format!(
12472                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
12473                        quote_ident(FTS_SOURCE_ID_COLUMN),
12474                        quote_ident(&index.name)
12475                    ))
12476                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12477            }
12478            self.create_fts_triggers(table, index)?;
12479            if !existed {
12480                self.rebuild_fts_projection(table, index)?;
12481            }
12482            Ok(())
12483        })();
12484        if let Err(error) = result {
12485            self.rollback_observation("syncular_fts_schema");
12486            return Err(error);
12487        }
12488        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
12489            self.rollback_observation("syncular_fts_schema");
12490            return Err(error.to_string());
12491        }
12492        Ok(())
12493    }
12494
12495    // -- persistence write-through --------------------------------------------
12496
12497    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
12498        let state = serde_json::json!({
12499            "requested": scope_map_to_json(&sub.requested),
12500            "params": sub.params,
12501            "cursor": sub.cursor,
12502            "bootstrapState": sub.bootstrap_state,
12503            "status": sub.state.name(),
12504            "reasonCode": sub.reason_code,
12505            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
12506            "syncedOnce": sub.synced_once,
12507        });
12508        self.conn.execute(
12509            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
12510            rusqlite::params![sub.id, sub.table, state.to_string()],
12511        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12512    }
12513
12514    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
12515        let ops: Vec<Value> = commit
12516            .ops
12517            .iter()
12518            .map(|op| {
12519                serde_json::json!({
12520                    "op": if op.upsert { "upsert" } else { "delete" },
12521                    "table": op.table,
12522                    "rowId": op.row_id,
12523                    "baseVersion": op.base_version,
12524                    "values": op.values.clone().map(Value::Object),
12525                })
12526            })
12527            .collect();
12528        self.conn
12529            .execute(
12530                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
12531                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
12532            )
12533            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12534        for operation in &commit.ops {
12535            let Some(values) = &operation.values else {
12536                continue;
12537            };
12538            let Some(table) = self.schema.table(&operation.table) else {
12539                continue;
12540            };
12541            for column in table
12542                .columns
12543                .iter()
12544                .filter(|column| column.ty == ColumnType::BlobRef)
12545            {
12546                let Some(Value::String(raw)) = values.get(&column.name) else {
12547                    continue;
12548                };
12549                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
12550                    continue;
12551                };
12552                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
12553                    continue;
12554                };
12555                self.conn
12556                    .execute(
12557                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
12558                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
12559                        rusqlite::params![commit.client_commit_id, blob_id],
12560                    )
12561                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12562            }
12563        }
12564        Ok(())
12565    }
12566
12567    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
12568        if let Some(commit) = self
12569            .outbox
12570            .iter()
12571            .find(|commit| commit.client_commit_id == client_commit_id)
12572        {
12573            for operation in &commit.ops {
12574                self.overlay_dirty.table(&operation.table);
12575            }
12576        }
12577
12578        self.conn
12579            .execute(
12580                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
12581                rusqlite::params![client_commit_id],
12582            )
12583            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12584        self.conn
12585            .execute(
12586                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
12587                rusqlite::params![client_commit_id],
12588            )
12589            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12590        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)", [])
12591            .map(|_| ())
12592            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12593    }
12594
12595    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
12596        match status {
12597            CommitOutcomeStatus::Applied => "applied",
12598            CommitOutcomeStatus::Cached => "cached",
12599            CommitOutcomeStatus::Conflict => "conflict",
12600            CommitOutcomeStatus::Rejected => "rejected",
12601        }
12602    }
12603
12604    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
12605        match resolution {
12606            CommitOutcomeResolution::Active => "active",
12607            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
12608            CommitOutcomeResolution::Superseded => "superseded",
12609            CommitOutcomeResolution::Dismissed => "dismissed",
12610        }
12611    }
12612
12613    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
12614        match value {
12615            "applied" => Ok(CommitOutcomeStatus::Applied),
12616            "cached" => Ok(CommitOutcomeStatus::Cached),
12617            "conflict" => Ok(CommitOutcomeStatus::Conflict),
12618            "rejected" => Ok(CommitOutcomeStatus::Rejected),
12619            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
12620        }
12621    }
12622
12623    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
12624        match value {
12625            "active" => Ok(CommitOutcomeResolution::Active),
12626            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
12627            "superseded" => Ok(CommitOutcomeResolution::Superseded),
12628            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
12629            _ => Err(format!(
12630                "invalid persisted commit outcome resolution {value:?}"
12631            )),
12632        }
12633    }
12634
12635    fn persist_commit_outcome(
12636        &self,
12637        client_commit_id: &str,
12638        status: CommitOutcomeStatus,
12639        results: &[CommitOperationOutcome],
12640        operations: Option<&[OutboxOp]>,
12641    ) -> Result<(), String> {
12642        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
12643        let operations_json = operations
12644            .map(|items| {
12645                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
12646            })
12647            .transpose()
12648            .map_err(|error| error.to_string())?;
12649        self.conn
12650            .execute(
12651                "INSERT INTO _syncular_commit_outcomes (
12652                   client_commit_id, status, recorded_at_ms, results_json,
12653                   operations_json, resolution
12654                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
12655                rusqlite::params![
12656                    client_commit_id,
12657                    Self::outcome_status_name(status),
12658                    self.clock_now_ms(),
12659                    results_json,
12660                    operations_json
12661                ],
12662            )
12663            .map(|_| ())
12664            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12665    }
12666
12667    /// §7.2: the journal holds a stored cell only in its canonical shape. A cell
12668    /// the byte-envelope rule refuses cannot be journaled verbatim.
12669    fn stored_cell_is_representable(cell: &Value) -> bool {
12670        cell.is_null()
12671            || cell.is_string()
12672            || cell.is_boolean()
12673            || cell.is_number()
12674            || is_canonical_bytes_envelope(cell)
12675    }
12676
12677    fn stored_row_is_representable(value: &Value) -> bool {
12678        value
12679            .as_object()
12680            .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
12681    }
12682
12683    fn stored_operation_is_representable(value: &Value) -> bool {
12684        value.get("table").is_some_and(Value::is_string)
12685            && value.get("rowId").is_some_and(Value::is_string)
12686            && value.get("baseVersion").is_none_or(|v| {
12687                v.as_i64()
12688                    .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
12689            })
12690            && match value.get("op").and_then(Value::as_str) {
12691                Some("upsert") => value
12692                    .get("values")
12693                    .is_some_and(Self::stored_row_is_representable),
12694                Some("delete") => value.get("values").is_none(),
12695                _ => false,
12696            }
12697    }
12698
12699    /// §7.2/§7.4.4: a dropped commit keeps its operation envelope in the journal
12700    /// only when the journal reader accepts the serialized operation, which is the
12701    /// rule `outcome_from_row` applies to this same row later.
12702    fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
12703        serde_json::to_value(CommitOperation::from(operation))
12704            .is_ok_and(|value| Self::stored_operation_is_representable(&value))
12705    }
12706
12707    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
12708        let StoredCommitOutcomeRow {
12709            sequence,
12710            client_commit_id,
12711            status,
12712            recorded_at_ms,
12713            results_json,
12714            operations_json,
12715            resolution,
12716            resolved_at_ms,
12717            replacement_client_commit_id,
12718        } = row;
12719        if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
12720            || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
12721            || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
12722        {
12723            return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
12724        }
12725        let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
12726        let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
12727        for result in &stored {
12728            let nested = match result.get("status").and_then(Value::as_str) {
12729                Some("conflict") => result.get("conflict"),
12730                Some("error") => result.get("rejection"),
12731                _ => None,
12732            };
12733            if nested
12734                .and_then(|record| record.get("operation"))
12735                .is_some_and(|op| !Self::stored_operation_is_representable(op))
12736                || (result.get("status").and_then(Value::as_str) == Some("conflict")
12737                    && nested
12738                        .and_then(|record| record.get("serverRow"))
12739                        .is_some_and(|row| !Self::stored_row_is_representable(row)))
12740            {
12741                return Err(invalid());
12742            }
12743        }
12744        let results: Vec<CommitOperationOutcome> =
12745            serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
12746        let has_conflict = results
12747            .iter()
12748            .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
12749        let has_error = results
12750            .iter()
12751            .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
12752        let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
12753        let redacted = results.is_empty() && operations_json.is_none();
12754        if match status {
12755            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
12756            CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
12757            CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
12758        } {
12759            return Err(invalid());
12760        }
12761        let operations: Option<Vec<CommitOperation>> = operations_json
12762            .map(|raw| {
12763                let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
12764                if !stored.iter().all(Self::stored_operation_is_representable) {
12765                    return Err(invalid());
12766                }
12767                serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
12768            })
12769            .transpose()?;
12770        for result in &results {
12771            match result {
12772                CommitOperationOutcome::Conflict { conflict }
12773                    if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
12774                {
12775                    return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
12776                }
12777                CommitOperationOutcome::Error { rejection } => {
12778                    if let Some(details) = &rejection.details {
12779                        let encoded = serde_json::to_string(details).map_err(|_| {
12780                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
12781                        })?;
12782                        RejectionDetails::parse(&encoded).map_err(|_| {
12783                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
12784                        })?;
12785                    }
12786                }
12787                _ => {}
12788            }
12789        }
12790        Ok(CommitOutcome {
12791            sequence,
12792            client_commit_id,
12793            status,
12794            recorded_at_ms,
12795            results,
12796            operations,
12797            retained_rows: None,
12798            resolution: Self::parse_outcome_resolution(&resolution)?,
12799            resolved_at_ms,
12800            replacement_client_commit_id,
12801        })
12802    }
12803
12804    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
12805    fn unique_conflicts(
12806        &self,
12807        table: &TableSchema,
12808        values: &Map<String, Value>,
12809        base: bool,
12810    ) -> Result<Vec<RetainedUniqueConflict>, String> {
12811        let source = if base {
12812            base_table(&table.name)
12813        } else {
12814            visible_table(&table.name)
12815        };
12816        let mut conflicts = Vec::new();
12817        for index in table.indexes.iter().filter(|index| index.unique) {
12818            let mut params = vec![json_param_to_sql(
12819                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
12820            )?];
12821            for name in &index.columns {
12822                let column = table
12823                    .columns
12824                    .iter()
12825                    .find(|column| column.name == *name)
12826                    .ok_or("sync.local_corrupt")?;
12827                let cell = json_to_column_value(column, values.get(name))?;
12828                params.push(owned_sql_value(RowParam::Cell(&cell))?);
12829            }
12830            let sql = format!(
12831                "SELECT * FROM {source} WHERE {} != ? AND {}",
12832                quote_ident(&table.primary_key),
12833                index
12834                    .columns
12835                    .iter()
12836                    .map(|column| format!("{} = ?", quote_ident(column)))
12837                    .collect::<Vec<_>>()
12838                    .join(" AND ")
12839            );
12840            let mut statement = self
12841                .conn
12842                .prepare_cached(&sql)
12843                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12844            let records = statement
12845                .query_map(rusqlite::params_from_iter(params), |record| {
12846                    let values = table
12847                        .columns
12848                        .iter()
12849                        .enumerate()
12850                        .map(|(index, column)| {
12851                            let value = sql_ref_to_json(column, record.get_ref(index)?);
12852                            json_to_column_value(column, Some(&value)).map_err(|message| {
12853                                rusqlite::Error::ToSqlConversionFailure(message.into())
12854                            })
12855                        })
12856                        .collect::<Result<Row, _>>()?;
12857                    Ok((values, record.get::<_, i64>(table.columns.len())?))
12858                })
12859                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12860            for record in records {
12861                let (row, version) =
12862                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12863                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
12864                conflicts.push(RetainedUniqueConflict {
12865                    index: index.name.clone(),
12866                    columns: index.columns.clone(),
12867                    row_id,
12868                    server_row: table
12869                        .columns
12870                        .iter()
12871                        .zip(&row)
12872                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
12873                        .collect(),
12874                    server_version: version,
12875                });
12876            }
12877        }
12878        Ok(conflicts)
12879    }
12880
12881    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
12882        if let Some((commit, initial)) = self
12883            .failed_commits
12884            .iter()
12885            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
12886        {
12887            let mut rows = Vec::new();
12888            for operation in &commit.ops {
12889                let table = self
12890                    .schema
12891                    .table(&operation.table)
12892                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
12893                let key =
12894                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
12895                        .map_err(|_| "sync.local_corrupt".to_owned())?;
12896                let base = self.stored_row(table, &operation.row_id, true)?;
12897                rows.push(RetainedCommitRow {
12898                    table: operation.table.clone(),
12899                    row_id: operation.row_id.clone(),
12900                    unique_conflicts: if operation.upsert {
12901                        self.unique_conflicts(
12902                            table,
12903                            initial
12904                                .get(&key)
12905                                .and_then(Value::as_object)
12906                                .or(operation.values.as_ref())
12907                                .ok_or("sync.local_corrupt")?,
12908                            true,
12909                        )?
12910                    } else {
12911                        Vec::new()
12912                    },
12913                    local_row: if operation.upsert {
12914                        initial
12915                            .get(&key)
12916                            .and_then(Value::as_object)
12917                            .cloned()
12918                            .or_else(|| operation.values.clone())
12919                    } else {
12920                        None
12921                    },
12922                    server_row: base.as_ref().map(|(values, _)| {
12923                        table
12924                            .columns
12925                            .iter()
12926                            .zip(values)
12927                            .map(|(column, value)| {
12928                                (column.name.clone(), column_value_to_json(value))
12929                            })
12930                            .collect()
12931                    }),
12932                    server_version: base.map(|(_, version)| version),
12933                });
12934            }
12935            outcome.retained_rows = Some(rows);
12936        }
12937        Ok(outcome)
12938    }
12939
12940    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
12941        let row = self
12942            .conn
12943            .query_row(
12944                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
12945                        resolution, resolved_at_ms, replacement_client_commit_id
12946                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
12947                rusqlite::params![client_commit_id],
12948                |row| {
12949                    Ok(StoredCommitOutcomeRow {
12950                        sequence: row.get(0)?,
12951                        client_commit_id: row.get(1)?,
12952                        status: row.get(2)?,
12953                        recorded_at_ms: row.get(3)?,
12954                        results_json: row.get(4)?,
12955                        operations_json: row.get(5)?,
12956                        resolution: row.get(6)?,
12957                        resolved_at_ms: row.get(7)?,
12958                        replacement_client_commit_id: row.get(8)?,
12959                    })
12960                },
12961            )
12962            .optional()
12963            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12964        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
12965            .transpose()
12966    }
12967
12968    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
12969        if query.limit == Some(0) {
12970            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
12971        }
12972        let mut sql = String::from(
12973            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
12974                    resolution, resolved_at_ms, replacement_client_commit_id
12975               FROM _syncular_commit_outcomes",
12976        );
12977        if query.active_only {
12978            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
12979        }
12980        sql.push_str(" ORDER BY seq DESC");
12981        if let Some(limit) = query.limit {
12982            sql.push_str(&format!(" LIMIT {limit}"));
12983        }
12984        let mut stmt = self
12985            .conn
12986            .prepare(&sql)
12987            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12988        let rows = stmt
12989            .query_map([], |row| {
12990                Ok(StoredCommitOutcomeRow {
12991                    sequence: row.get(0)?,
12992                    client_commit_id: row.get(1)?,
12993                    status: row.get(2)?,
12994                    recorded_at_ms: row.get(3)?,
12995                    results_json: row.get(4)?,
12996                    operations_json: row.get(5)?,
12997                    resolution: row.get(6)?,
12998                    resolved_at_ms: row.get(7)?,
12999                    replacement_client_commit_id: row.get(8)?,
13000                })
13001            })
13002            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13003        let mut outcomes = Vec::new();
13004        for row in rows {
13005            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
13006                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13007            )?)?);
13008        }
13009        Ok(outcomes)
13010    }
13011
13012    fn prune_commit_outcomes(&self) -> Result<(), String> {
13013        #[cfg(test)]
13014        self.outcome_prune_count
13015            .set(self.outcome_prune_count.get() + 1);
13016        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
13017        let count = self
13018            .conn
13019            .query_row(
13020                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
13021                [],
13022                |row| row.get::<_, i64>(0),
13023            )
13024            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13025            as usize;
13026        let excess = count.saturating_sub(max_entries);
13027        if excess == 0 {
13028            return Ok(());
13029        }
13030        let mut stmt = self
13031            .conn
13032            .prepare(
13033                "SELECT seq FROM _syncular_commit_outcomes
13034                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
13035                  ORDER BY seq ASC LIMIT ?1",
13036            )
13037            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13038        let rows = stmt
13039            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
13040            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13041        let sequences = rows
13042            .collect::<Result<Vec<_>, _>>()
13043            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13044        drop(stmt);
13045        for sequence in sequences {
13046            self.conn
13047                .execute(
13048                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
13049                    rusqlite::params![sequence],
13050                )
13051                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13052        }
13053        Ok(())
13054    }
13055
13056    // -- driver surface ---------------------------------------------------------
13057
13058    pub fn subscribe(
13059        &mut self,
13060        id: String,
13061        table: String,
13062        scopes: Vec<(String, Vec<String>)>,
13063        params: Option<String>,
13064    ) -> Result<(), String> {
13065        if self.schema.table(&table).is_none() {
13066            return Err(format!("unknown table {table:?}"));
13067        }
13068        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
13069            let same_intent = existing.table == table
13070                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
13071                && existing.params == params;
13072            if same_intent {
13073                return Ok(());
13074            }
13075            return Err(
13076                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
13077                    .to_owned(),
13078            );
13079        }
13080        let sub = Subscription {
13081            id: id.clone(),
13082            table,
13083            requested: scopes,
13084            params,
13085            cursor: -1,
13086            bootstrap_state: None,
13087            state: SubState::Active,
13088            reason_code: None,
13089            effective: None,
13090            synced_once: false,
13091        };
13092        self.persist_sub(&sub)?;
13093        self.subs.push(sub);
13094        Ok(())
13095    }
13096
13097    pub fn unsubscribe(&mut self, id: &str) {
13098        if self.subs.iter().any(|s| s.id == id) {
13099            self.cancel_sync_round();
13100        }
13101        self.subs.retain(|s| s.id != id);
13102        let _ = self.conn.execute(
13103            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13104            rusqlite::params![id],
13105        );
13106    }
13107
13108    // -- windowed subscriptions (§4.8) ------------------------------------------
13109
13110    /// §4.8: set the live window units for a base — a value-sharded family
13111    /// of subscriptions, one per unit. Added units get fresh subscriptions
13112    /// (image-lane bootstrap on the next sync); removed units are
13113    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
13114    /// Idempotent; re-entry cancels any deferred eviction.
13115    pub fn set_window(
13116        &mut self,
13117        base: &WindowBase,
13118        units: &[String],
13119    ) -> Result<CommandEffects, String> {
13120        let table = self
13121            .schema
13122            .table(&base.table)
13123            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
13124        if table.scope_column(&base.variable).is_none() {
13125            return Err(format!(
13126                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
13127                base.table, base.variable
13128            ));
13129        }
13130        let base_key = window_base_key(base);
13131        let wanted: std::collections::HashSet<&String> = units.iter().collect();
13132        let live = self.load_window_units(&base_key);
13133        self.begin_observation("syncular_window")?;
13134        let mut batch = ChangeAccumulator::default();
13135        let mut changed = false;
13136
13137        let prior_subs = self.subs.clone();
13138        let widened = (|| {
13139            // Widen: units wanted but not live → fresh subscription + registry row.
13140            for unit in units {
13141                if live.iter().any(|(u, _)| u == unit) {
13142                    continue;
13143                }
13144                let sub_id = derive_sub_id(base, unit);
13145                self.delete_pending_evict(&sub_id)?;
13146                self.insert_window_unit(&base_key, unit, &sub_id)?;
13147                self.subscribe(
13148                    sub_id,
13149                    base.table.clone(),
13150                    unit_scopes(base, unit),
13151                    base.params.clone(),
13152                )?;
13153                batch.window(&base_key, &base.table, unit);
13154                changed = true;
13155            }
13156
13157            self.finish_observation("syncular_window", batch)
13158        })();
13159        if let Err(error) = widened {
13160            self.rollback_observation("syncular_window");
13161            self.subs = prior_subs;
13162            return Err(error);
13163        }
13164        for (unit, sub_id) in live {
13165            if wanted.contains(&unit) {
13166                continue;
13167            }
13168            self.rebuild_overlay_if_dirty()?;
13169            let prior_subs = self.subs.clone();
13170            self.begin_observation("syncular_window")?;
13171            let effective = self
13172                .subs
13173                .iter()
13174                .find(|sub| sub.id == sub_id)
13175                .and_then(|sub| sub.effective.clone())
13176                .unwrap_or_else(|| unit_scopes(base, &unit));
13177            let mut batch = ChangeAccumulator::default();
13178            self.record_scope_map(&mut batch, &base.table, &effective);
13179            batch.window(&base_key, &base.table, &unit);
13180            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
13181            let remaining = match result {
13182                Ok(remaining) => remaining,
13183                Err(error) => {
13184                    self.rollback_observation("syncular_window");
13185                    self.subs = prior_subs;
13186                    return Err(error);
13187                }
13188            };
13189            if let Err(error) = self.finish_observation("syncular_window", batch) {
13190                self.rollback_observation("syncular_window");
13191                self.subs = prior_subs;
13192                return Err(error);
13193            }
13194            if remaining {
13195                self.drain_pending_evictions()?;
13196            }
13197            changed = true;
13198        }
13199        Ok(if changed {
13200            CommandEffects::interactive()
13201        } else {
13202            CommandEffects::none()
13203        })
13204    }
13205
13206    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
13207    /// the subset still bootstrap-pending. Registration alone is not
13208    /// completeness — a unit is pending until its subscription completes a
13209    /// bootstrap round (cursor advances past -1 with no resume token held).
13210    pub fn window_state(&self, base: &WindowBase) -> WindowState {
13211        let mut units = Vec::new();
13212        let mut pending = Vec::new();
13213        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
13214            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
13215                Some(sub) => {
13216                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
13217                }
13218                None => true,
13219            };
13220            if is_pending {
13221                pending.push(unit.clone());
13222            }
13223            units.push(unit);
13224        }
13225        WindowState { units, pending }
13226    }
13227
13228    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
13229        let mut stmt = match self
13230            .conn
13231            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
13232        {
13233            Ok(stmt) => stmt,
13234            Err(_) => return Vec::new(),
13235        };
13236        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
13237            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13238        });
13239        match rows {
13240            Ok(rows) => rows.filter_map(Result::ok).collect(),
13241            Err(_) => Vec::new(),
13242        }
13243    }
13244
13245    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
13246        let mut stmt = match self.conn.prepare(
13247            "SELECT windows.base, windows.unit, subscriptions.tbl
13248               FROM _syncular_windows AS windows
13249               JOIN _syncular_subscriptions AS subscriptions
13250                 ON subscriptions.id = windows.sub_id
13251               ORDER BY windows.base, windows.unit",
13252        ) {
13253            Ok(stmt) => stmt,
13254            Err(_) => return Vec::new(),
13255        };
13256        let rows = stmt.query_map([], |row| {
13257            Ok((
13258                row.get::<_, String>(0)?,
13259                row.get::<_, String>(1)?,
13260                row.get::<_, String>(2)?,
13261            ))
13262        });
13263        match rows {
13264            Ok(rows) => rows.filter_map(Result::ok).collect(),
13265            Err(_) => Vec::new(),
13266        }
13267    }
13268
13269    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
13270        self.conn
13271            .query_row(
13272                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
13273                rusqlite::params![sub_id],
13274                |row| Ok((row.get(0)?, row.get(1)?)),
13275            )
13276            .ok()
13277    }
13278
13279    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
13280        self.conn
13281            .execute(
13282                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
13283                rusqlite::params![base_key, unit, sub_id],
13284            )
13285            .map(|_| ())
13286            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13287    }
13288
13289    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
13290        self.conn
13291            .execute(
13292                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
13293                rusqlite::params![base_key, unit],
13294            )
13295            .map(|_| ())
13296            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13297    }
13298
13299    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
13300    /// Deletes the unit's rows except those pinned by a pending outbox
13301    /// commit (E1); records a deferred eviction if any pin remains; discards
13302    /// the subscription's cursor/resume/effective-echo (E3) and version
13303    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
13304    /// nothing.
13305    fn evict_unit(
13306        &mut self,
13307        base_key: &str,
13308        base: &WindowBase,
13309        unit: &str,
13310        sub_id: &str,
13311    ) -> Result<bool, String> {
13312        let effective = self
13313            .subs
13314            .iter()
13315            .find(|s| s.id == sub_id)
13316            .and_then(|s| s.effective.clone())
13317            .unwrap_or_else(|| unit_scopes(base, unit));
13318        let pinned = self.pinned_row_ids(&base.table);
13319        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
13320        self.delete_window_unit(base_key, unit)?;
13321        self.conn
13322            .execute(
13323                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13324                [sub_id],
13325            )
13326            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13327        self.subs.retain(|sub| sub.id != sub_id);
13328        if deferred {
13329            self.save_pending_evict(sub_id, &base.table, &effective)?;
13330        } else {
13331            self.delete_pending_evict(sub_id)?;
13332        }
13333        Ok(remaining)
13334    }
13335
13336    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
13337    /// primary keys. Returns (more unpinned work, any scoped rows remain).
13338    /// A missing scope-column mapping fails without deleting rows.
13339    fn evict_scope_rows(
13340        &self,
13341        table_name: &str,
13342        effective: &[(String, Vec<String>)],
13343        pinned: &std::collections::HashSet<String>,
13344    ) -> Result<(bool, bool), String> {
13345        if effective.is_empty() {
13346            return Ok((false, false));
13347        }
13348        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
13349        let mut clauses = Vec::new();
13350        let mut params: Vec<SqlValue> = Vec::new();
13351        for (variable, values) in effective {
13352            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
13353            if values.is_empty() {
13354                return Ok((false, false));
13355            }
13356            let holes = values
13357                .iter()
13358                .map(|v| {
13359                    params.push(SqlValue::Text(v.clone()));
13360                    "?"
13361                })
13362                .collect::<Vec<_>>()
13363                .join(", ");
13364            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
13365        }
13366        let scope = clauses.join(" AND ");
13367        let scope_params = params.len();
13368        let pk = quote_ident(&table.primary_key);
13369        if !pinned.is_empty() {
13370            let holes = pinned
13371                .iter()
13372                .map(|id| {
13373                    params.push(SqlValue::Text(id.clone()));
13374                    "?"
13375                })
13376                .collect::<Vec<_>>()
13377                .join(", ");
13378            clauses.push(format!("{pk} NOT IN ({holes})"));
13379        }
13380        let predicate = clauses.join(" AND ");
13381        let base = base_table(table_name);
13382        let visible = visible_table(table_name);
13383        let mut ids: Vec<SqlValue> = Vec::new();
13384        for source in [&base, &visible] {
13385            let mut stmt = self
13386                .conn
13387                .prepare_cached(&format!(
13388                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
13389                ))
13390                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13391            ids = stmt
13392                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
13393                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13394                .collect::<Result<_, _>>()
13395                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13396            if !ids.is_empty() {
13397                break;
13398            }
13399        }
13400        if !ids.is_empty() {
13401            for id in &ids {
13402                self.conn
13403                    .execute(
13404                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
13405                        rusqlite::params![table_name, id],
13406                    )
13407                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13408            }
13409            let holes = vec!["?"; ids.len()].join(", ");
13410            for source in [&base, &visible] {
13411                self.conn
13412                    .execute(
13413                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
13414                        rusqlite::params_from_iter(&ids),
13415                    )
13416                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13417            }
13418        }
13419        let mut remaining = false;
13420        let mut deferred = false;
13421        for source in [&base, &visible] {
13422            remaining |= self
13423                .conn
13424                .query_row(
13425                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
13426                    rusqlite::params_from_iter(&params),
13427                    |row| row.get::<_, bool>(0),
13428                )
13429                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13430            deferred |= self
13431                .conn
13432                .query_row(
13433                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
13434                    rusqlite::params_from_iter(&params[..scope_params]),
13435                    |row| row.get::<_, bool>(0),
13436                )
13437                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13438        }
13439        Ok((remaining, deferred))
13440    }
13441
13442    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
13443    fn drain_pending_evictions(&mut self) -> Result<(), String> {
13444        let pending = self.load_pending_evictions()?;
13445        if pending.is_empty() {
13446            return Ok(());
13447        }
13448        self.rebuild_overlay_if_dirty()?;
13449        for (sub_id, table_name, effective) in pending {
13450            if self.schema.table(&table_name).is_none() {
13451                self.delete_pending_evict(&sub_id)?;
13452                continue;
13453            }
13454            loop {
13455                std::thread::yield_now();
13456                self.begin_observation("syncular_evict")?;
13457                let pinned = self.pinned_row_ids(&table_name);
13458                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
13459                let (remaining, deferred) = match result {
13460                    Ok(result) => result,
13461                    Err(error) => {
13462                        self.rollback_observation("syncular_evict");
13463                        return Err(error);
13464                    }
13465                };
13466                if !deferred {
13467                    if let Err(error) = self.delete_pending_evict(&sub_id) {
13468                        self.rollback_observation("syncular_evict");
13469                        return Err(error);
13470                    }
13471                }
13472                let mut batch = ChangeAccumulator::default();
13473                self.record_scope_map(&mut batch, &table_name, &effective);
13474                if let Err(error) = self.finish_observation("syncular_evict", batch) {
13475                    self.rollback_observation("syncular_evict");
13476                    return Err(error);
13477                }
13478                if !remaining {
13479                    break;
13480                }
13481            }
13482        }
13483        Ok(())
13484    }
13485
13486    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
13487    /// commit — rows that MUST NOT be evicted until the commit drains.
13488    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
13489        let mut pinned = std::collections::HashSet::new();
13490        for commit in self
13491            .outbox
13492            .iter()
13493            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
13494        {
13495            for op in &commit.ops {
13496                if op.table == table {
13497                    pinned.insert(op.row_id.clone());
13498                }
13499            }
13500        }
13501        pinned
13502    }
13503
13504    fn save_pending_evict(
13505        &self,
13506        sub_id: &str,
13507        table: &str,
13508        effective: &[(String, Vec<String>)],
13509    ) -> Result<(), String> {
13510        self.conn.execute(
13511            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
13512            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
13513        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13514    }
13515
13516    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
13517        self.conn
13518            .execute(
13519                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
13520                rusqlite::params![sub_id],
13521            )
13522            .map(|_| ())
13523            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13524    }
13525
13526    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
13527        let mut stmt = self
13528            .conn
13529            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
13530            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13531        let rows = stmt
13532            .query_map([], |row| {
13533                Ok((
13534                    row.get::<_, String>(0)?,
13535                    row.get::<_, String>(1)?,
13536                    row.get::<_, String>(2)?,
13537                ))
13538            })
13539            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13540        rows.map(|row| {
13541            let (sub_id, table, json) =
13542                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13543            let value: Value = serde_json::from_str(&json)
13544                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
13545            let effective = json_to_scope_map(&value)
13546                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
13547            Ok((sub_id, table, effective))
13548        })
13549        .collect()
13550    }
13551
13552    /// Select failed-intent retention; reload durable retained aggregates.
13553    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
13554        self.retain_failed_commits = enabled;
13555        for (commit, _) in &self.failed_commits {
13556            for operation in &commit.ops {
13557                self.overlay_dirty.table(&operation.table);
13558            }
13559        }
13560        self.failed_commits = self.load_failed_commits()?;
13561        for (commit, _) in &self.failed_commits {
13562            for operation in &commit.ops {
13563                self.overlay_dirty.table(&operation.table);
13564            }
13565        }
13566        Ok(())
13567    }
13568
13569    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
13570        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
13571            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13572        let rows = statement
13573            .query_map([], |row| {
13574                Ok((
13575                    row.get::<_, String>(0)?,
13576                    row.get::<_, String>(1)?,
13577                    row.get::<_, String>(2)?,
13578                ))
13579            })
13580            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13581        rows.map(|row| {
13582            let (id, operations, initial) =
13583                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13584            let operations: Vec<CommitOperation> =
13585                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
13586            let initial =
13587                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
13588            Ok((
13589                OutboxCommit {
13590                    client_commit_id: id,
13591                    ops: operations
13592                        .into_iter()
13593                        .map(|op| OutboxOp {
13594                            upsert: op.op == "upsert",
13595                            table: op.table,
13596                            row_id: op.row_id,
13597                            base_version: op.base_version,
13598                            values: op.values,
13599                        })
13600                        .collect(),
13601                },
13602                initial,
13603            ))
13604        })
13605        .collect()
13606    }
13607
13608    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
13609        let mut initial = Map::new();
13610        for operation in operations {
13611            let table = self
13612                .schema
13613                .table(&operation.table)
13614                .ok_or_else(|| "sync.unknown_table".to_owned())?;
13615            let initial_row = match self.stored_row(table, &operation.row_id, true)? {
13616                Some(row) => Some(row),
13617                None => self.visible_row(table, &operation.row_id)?,
13618            };
13619            if let Some((values, _)) = initial_row {
13620                let mut intended: Map<String, Value> = table
13621                    .columns
13622                    .iter()
13623                    .zip(values.iter())
13624                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13625                    .collect();
13626                if let Some(patch) = &operation.values {
13627                    intended.extend(patch.clone());
13628                }
13629                initial.insert(
13630                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13631                        .map_err(|_| "sync.local_corrupt".to_owned())?,
13632                    Value::Object(intended),
13633                );
13634            }
13635        }
13636        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
13637            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
13638            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
13639            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13640        self.failed_commits.push((
13641            OutboxCommit {
13642                client_commit_id: id.to_owned(),
13643                ops: operations.to_vec(),
13644            },
13645            initial,
13646        ));
13647        Ok(())
13648    }
13649
13650    fn drop_failed_commits(
13651        &mut self,
13652        matches: impl Fn(&str, &Map<String, Value>) -> bool,
13653    ) -> Result<Vec<OutboxCommit>, String> {
13654        let mut doomed = self.drop_acknowledged_rows(&matches)?;
13655        for (commit, initial) in &self.failed_commits {
13656            for operation in &commit.ops {
13657                let key =
13658                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13659                        .map_err(|_| "sync.local_corrupt".to_owned())?;
13660                if initial
13661                    .get(&key)
13662                    .and_then(Value::as_object)
13663                    .is_some_and(|values| matches(&operation.table, values))
13664                {
13665                    doomed.push(commit.clone());
13666                    break;
13667                }
13668            }
13669        }
13670        for commit in &doomed {
13671            self.conn
13672                .execute(
13673                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
13674                    rusqlite::params![commit.client_commit_id],
13675                )
13676                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13677        }
13678        for commit in &doomed {
13679            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))?;
13680        }
13681        self.conflicts.retain(|record| {
13682            !doomed
13683                .iter()
13684                .any(|commit| commit.client_commit_id == record.client_commit_id)
13685        });
13686        self.rejections.retain(|record| {
13687            !doomed
13688                .iter()
13689                .any(|commit| commit.client_commit_id == record.client_commit_id)
13690        });
13691        self.failed_commits.retain(|(commit, _)| {
13692            !doomed
13693                .iter()
13694                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
13695        });
13696        for commit in &doomed {
13697            for operation in &commit.ops {
13698                self.overlay_dirty.table(&operation.table);
13699            }
13700        }
13701        Ok(doomed)
13702    }
13703
13704    /// Record one commit of local mutations. The returned commit ID is the
13705    /// §2.3 idempotency key. Failures are structured so callers read
13706    /// `code`/`message`/`details` instead of parsing a string prefix.
13707    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
13708        let prior = self.storage_failure.borrow_mut().take();
13709        let outcome = (|| -> Result<String, String> {
13710            if mutations.is_empty() {
13711                return Err(
13712                    "sync.invalid_request: a commit must contain at least one operation (§6.1)"
13713                        .to_owned(),
13714                );
13715            }
13716            let mut ops = Vec::with_capacity(mutations.len());
13717            for mutation in mutations {
13718                match mutation {
13719                    Mutation::Patch {
13720                        table,
13721                        values,
13722                        base_version,
13723                    } => {
13724                        let schema_table = self
13725                            .schema
13726                            .table(&table)
13727                            .ok_or_else(|| "sync.unknown_table".to_owned())?;
13728                        let values = normalize_values_casing(schema_table, values)?;
13729                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
13730                        ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
13731                    }
13732                    Mutation::Upsert {
13733                        table,
13734                        values,
13735                        base_version,
13736                    } => {
13737                        let schema_table = self.schema.table(&table).ok_or_else(|| {
13738                            format!("sync.unknown_table: unknown table {table:?}")
13739                        })?;
13740                        // §5: value keys are accepted in snake_case AND the
13741                        // generated row types' camelCase; normalize to SQL truth
13742                        // before the pk lookup / codec see them.
13743                        let values = normalize_values_casing(schema_table, values)?;
13744                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
13745                        // §6.7 `mutate` marks every column present: fill missing
13746                        // nullable columns with NULL before the payload is built.
13747                        let values = full_row_values(schema_table, values)?;
13748                        // §5.11: validate the payload encodes with the current
13749                        // codec. A key-selection or unknown-key failure is a
13750                        // durable rejection at the push seam (§10.3), never an
13751                        // author-time error. Full-row `mutate` presents every
13752                        // column, so no stored fallback is needed.
13753                        self.validate_author_encode(schema_table, &row_id, &values)?;
13754                        ops.push(OutboxOp {
13755                            upsert: true,
13756                            table,
13757                            row_id,
13758                            base_version,
13759                            values: Some(values),
13760                        });
13761                    }
13762                    Mutation::Delete {
13763                        table,
13764                        row_id,
13765                        base_version,
13766                    } => {
13767                        if self.schema.table(&table).is_none() {
13768                            return Err(format!("sync.unknown_table: unknown table {table:?}"));
13769                        }
13770                        ops.push(OutboxOp {
13771                            upsert: false,
13772                            table,
13773                            row_id,
13774                            base_version,
13775                            values: None,
13776                        });
13777                    }
13778                }
13779            }
13780            self.record_outbox_commit(ops)
13781        })();
13782        self.finish_authoring(prior, outcome)
13783    }
13784
13785    /// Scope the retained `storage_failure` diagnostic to this call so an
13786    /// earlier operation's failure cannot classify a new one, and surface the
13787    /// call's own classified failure with its details. A fresh classified
13788    /// failure replaces the retained diagnostic; otherwise the earlier one is
13789    /// kept for the next sync.
13790    fn finish_authoring(
13791        &self,
13792        prior: Option<QueryReadFailure>,
13793        outcome: Result<String, String>,
13794    ) -> Result<String, ClientError> {
13795        let fresh = self.storage_failure.borrow_mut().take();
13796        *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
13797        match outcome {
13798            Ok(id) => Ok(id),
13799            Err(message) => Err(fresh
13800                .map(ClientError::from)
13801                .unwrap_or_else(|| ClientError::from(message))),
13802        }
13803    }
13804
13805    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
13806        let commit = OutboxCommit {
13807            client_commit_id: uuid::Uuid::new_v4().to_string(),
13808            ops,
13809        };
13810        self.begin_observation("syncular_mutation")?;
13811        let mut batch = ChangeAccumulator::default();
13812        for op in &commit.ops {
13813            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
13814            if let Some(values) = &op.values {
13815                if let Some(table) = self.schema.table(&op.table) {
13816                    for scope in &table.scope_variables {
13817                        if let Some(Value::String(value)) = values.get(&scope.column) {
13818                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
13819                            precise = true;
13820                        }
13821                    }
13822                }
13823            }
13824            if !precise {
13825                batch.table(&op.table);
13826            }
13827        }
13828        if let Err(error) = self.persist_outbox_insert(&commit) {
13829            self.rollback_observation("syncular_mutation");
13830            return Err(error);
13831        }
13832        let was_dirty = self.overlay_dirty.snapshot();
13833        // A clean overlay contains the FIFO fold of every previous commit, so
13834        // the new commit applies over it. §7.1: an operation that does not
13835        // apply (a secondary unique collision) fails the whole commit; the
13836        // savepoint rollback removes the outbox entry and every visible write.
13837        if let Err(error) = self.rebuild_overlay_if_dirty() {
13838            self.rollback_observation("syncular_mutation");
13839            self.overlay_dirty.restore(was_dirty.clone());
13840            return Err(error);
13841        }
13842        if let Some(error) = commit
13843            .ops
13844            .iter()
13845            .find_map(|op| self.apply_outbox_op(op).err())
13846        {
13847            self.rollback_observation("syncular_mutation");
13848            self.overlay_dirty.restore(was_dirty.clone());
13849            return Err(error);
13850        }
13851        let id = commit.client_commit_id.clone();
13852        self.outbox.push(commit);
13853        batch.status = true;
13854        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
13855            self.rollback_observation("syncular_mutation");
13856            self.outbox.pop();
13857            self.overlay_dirty.restore(was_dirty.clone());
13858            return Err(error);
13859        }
13860        Ok(id)
13861    }
13862
13863    /// Record one §6.1 sparse upsert: the primary key plus the supplied
13864    /// non-scope columns are present; every other column is absent and stays
13865    /// untouched on the server and in the local overlay. An absent local base
13866    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
13867    /// Failures are structured so callers read `code`/`message`/`details`.
13868    pub fn patch(
13869        &mut self,
13870        table: &str,
13871        row_id: &str,
13872        partial: Map<String, Value>,
13873        base_version: Option<i64>,
13874    ) -> Result<String, ClientError> {
13875        let prior = self.storage_failure.borrow_mut().take();
13876        let outcome = self.perform_patch(table, row_id, partial, base_version);
13877        self.finish_authoring(prior, outcome)
13878    }
13879
13880    /// The unscoped patch body. Public authoring entry points scope
13881    /// `storage_failure` around the whole operation, including any pre-read.
13882    fn perform_patch(
13883        &mut self,
13884        table: &str,
13885        row_id: &str,
13886        partial: Map<String, Value>,
13887        base_version: Option<i64>,
13888    ) -> Result<String, String> {
13889        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
13890        self.record_outbox_commit(vec![operation])
13891    }
13892
13893    fn prepare_patch(
13894        &self,
13895        table: &str,
13896        row_id: &str,
13897        partial: Map<String, Value>,
13898        base_version: Option<i64>,
13899    ) -> Result<OutboxOp, String> {
13900        let schema_table = self
13901            .schema
13902            .table(table)
13903            .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
13904        let partial = normalize_values_casing(schema_table, partial)?;
13905        if let Some(pk) = partial.get(&schema_table.primary_key) {
13906            if pk.as_str() != Some(row_id) {
13907                return Err(format!(
13908                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
13909                ));
13910            }
13911        }
13912        // Existence reads only the key. Locked encrypted values stay opaque;
13913        // decoding the complete row here would reject a valid plain patch.
13914        let exists: bool = self
13915            .conn
13916            .query_row(
13917                &format!(
13918                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
13919                    visible_table(table),
13920                    row_id_predicate(schema_table)
13921                ),
13922                rusqlite::params![row_id],
13923                |row| row.get(0),
13924            )
13925            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13926        if !exists {
13927            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
13928        }
13929        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
13930        // server accepts a present scope column whose value equals the
13931        // stored row and applies it as a no-op, so a decoded envelope
13932        // round-trips. The local row is the only value the client can prove
13933        // equality against; when the row is absent locally the client cannot
13934        // prove equality against the server's stored row and fails closed.
13935        // A primary key that is also a scope column needs no stored-row
13936        // read: the primary key in a sparse payload is by construction the
13937        // row id being patched (§6.1), so its value is proven equal already.
13938        for scope in &schema_table.scope_variables {
13939            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
13940                continue;
13941            }
13942            let Some(column) = schema_table
13943                .columns
13944                .iter()
13945                .find(|column| column.name == scope.column)
13946            else {
13947                continue;
13948            };
13949            let stored = self
13950                .conn
13951                .query_row(
13952                    &format!(
13953                        "SELECT {} FROM {} WHERE {}",
13954                        quote_ident(&scope.column),
13955                        visible_table(&schema_table.name),
13956                        row_id_predicate(schema_table)
13957                    ),
13958                    rusqlite::params![row_id],
13959                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
13960                )
13961                .optional()
13962                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13963            // Decode the supplied value through the same column-type path the
13964            // rest of the patch encoding uses, so a value that only equals
13965            // the stored one in another number representation still compares
13966            // equal.
13967            let incoming = json_to_column_value(column, partial.get(&scope.column))
13968                .ok()
13969                .map(|value| column_value_to_json(&value));
13970            // A stored blob never equals a patch value, matching the server's
13971            // scope-column comparison (§3.4): bytes are unstorable as scope
13972            // values.
13973            let equal = stored.as_ref().is_some_and(|stored| {
13974                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
13975            });
13976            if !equal {
13977                return Err(format!(
13978                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
13979                    scope.column
13980                ));
13981            }
13982        }
13983        let mut values = partial;
13984        // Presence-set semantics: a proven-equal scope column is dropped, so
13985        // the patch leaves the stored value untouched (§6.2).
13986        for scope in &schema_table.scope_variables {
13987            values.remove(&scope.column);
13988        }
13989        values.insert(
13990            schema_table.primary_key.clone(),
13991            Value::from(row_id.to_owned()),
13992        );
13993        // §5.11: structural validation only; an absent key-id selector is
13994        // resolved at the push seam from the stored local row, and a
13995        // key-selection or unknown-key failure becomes a durable
13996        // `client.encrypt_failed` rejection there (§10.3), never an
13997        // author-time error.
13998        self.validate_author_encode(schema_table, row_id, &values)?;
13999        Ok(OutboxOp {
14000            upsert: true,
14001            table: table.to_owned(),
14002            row_id: row_id.to_owned(),
14003            base_version,
14004            values: Some(values),
14005        })
14006    }
14007
14008    /// §5.11 key-id fallback: the stored local row's selector value as a
14009    /// positional row (only the selector slot filled). Returns None when no
14010    /// selector is configured, the row is locally absent, or the stored
14011    /// value is not a usable key id.
14012    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
14013        let selector = self.encryption.key_id_columns.get(&table.name)?;
14014        let index = table
14015            .columns
14016            .iter()
14017            .position(|column| &column.name == selector)?;
14018        let sql = format!(
14019            "SELECT {} FROM {} WHERE {}",
14020            quote_ident(selector),
14021            visible_table(&table.name),
14022            row_id_predicate(table)
14023        );
14024        let key: Option<String> = self
14025            .conn
14026            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
14027            .ok()?;
14028        let key = key.filter(|key| !key.is_empty())?;
14029        let mut fallback: Row = vec![None; table.columns.len()];
14030        fallback[index] = Some(ColumnValue::String(key));
14031        Some(fallback)
14032    }
14033
14034    /// §5.11/§10.3 author-time encode check: a structural failure (a
14035    /// non-nullable NULL, a value the codec rejects) is an author-time
14036    /// error; a key-selection or unknown-key failure is deferred to the push
14037    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
14038    fn validate_author_encode(
14039        &self,
14040        table: &TableSchema,
14041        row_id: &str,
14042        values: &Map<String, Value>,
14043    ) -> Result<(), String> {
14044        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
14045            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
14046            result => result.map(|_| ()),
14047        }
14048    }
14049
14050    /// §5.11/§10.3: drop every pending commit whose push encode fails with
14051    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
14052    /// raise one durable rejection per commit. `sync()` never aborts for an
14053    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
14054    /// undoing the dropped commit's purely-optimistic rows.
14055    /// §7.1/§7.4.4: drop pending commits whose values the current codec refuses
14056    /// (an envelope or a type an earlier version accepted). `with_encryption`
14057    /// additionally drops commits whose §5.11 encryption cannot resolve a key;
14058    /// the startup boundary leaves those to the send seam, where hosts configure
14059    /// keys after open.
14060    /// Recovery runs as one observation. An enclosing observation (the guarded
14061    /// schema reset) passes its `batch`, so the rejection, outcome, and table
14062    /// domains join that change event instead of publishing a second one.
14063    fn drop_unencodable_outbox(
14064        &mut self,
14065        with_encryption: bool,
14066        batch: Option<&mut ChangeAccumulator>,
14067    ) -> Result<bool, String> {
14068        let failures = self
14069            .outbox
14070            .iter()
14071            .filter_map(|commit| {
14072                commit
14073                    .ops
14074                    .iter()
14075                    .find_map(|op| {
14076                        let values = op.values.as_ref().filter(|_| op.upsert)?;
14077                        let table = self.schema.table(&op.table)?;
14078                        let fallback = self.stored_key_fallback(table, &op.row_id);
14079                        match encode_sparse_row_json(
14080                            table,
14081                            &op.row_id,
14082                            values,
14083                            &self.encryption,
14084                            fallback.as_ref(),
14085                        ) {
14086                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
14087                                with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
14088                            }
14089                            // §7.1/§7.4.4: a value the current codec refuses was
14090                            // persisted by a version that accepted it; it leaves
14091                            // through the incompatible-outbox path, so the FIFO
14092                            // queue never wedges.
14093                            Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
14094                                LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
14095                                OUTBOX_INCOMPATIBLE_CODE,
14096                            )),
14097                            _ => None,
14098                        }
14099                    })
14100                    .map(|(error, code)| (commit.clone(), error, code))
14101            })
14102            .collect::<Vec<_>>();
14103        if failures.is_empty() {
14104            return Ok(false);
14105        }
14106        // §7.4.4: one observation covers the durable outcome writes, the outbox
14107        // removal, the in-memory drop, and the overlay rebuild, so a storage fault
14108        // leaves the replica and the client unchanged, a successful recovery
14109        // publishes one batch with the rejection, outcome, and table domains, and
14110        // an ordinary open with nothing to recover publishes nothing.
14111        let prior_outbox = self.outbox.clone();
14112        let prior_rejections = self.rejections.clone();
14113        // §5.11 retention and a self-published observation mutate two more
14114        // in-memory fields before this transaction ends, so the rollback below
14115        // restores them with the rest.
14116        let prior_failed_commits = self.failed_commits.clone();
14117        let prior_changes = self.change_queue.len();
14118        let prior_intents = self.sync_intent_queue.len();
14119        let prior_last_change = self.last_change.clone();
14120        let prior_overlay_dirty = self.overlay_dirty.snapshot();
14121        let owns_observation = batch.is_none();
14122        let owns_transaction = self.conn.is_autocommit();
14123        let mut domains = ChangeAccumulator::default();
14124        if owns_observation {
14125            self.begin_observation("syncular_value_recovery")?;
14126        }
14127        let dropped = (|| -> Result<(), String> {
14128            for (commit, message, code) in failures {
14129                if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
14130                    self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
14131                }
14132                let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
14133                let rejection = RejectionRecord {
14134                    client_commit_id: commit.client_commit_id.clone(),
14135                    op_index: 0,
14136                    code: code.to_owned(),
14137                    message,
14138                    retryable: false,
14139                    details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
14140                        field_paths: None,
14141                        reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
14142                        required_action: None,
14143                        references: None,
14144                    }),
14145                    operation: representable
14146                        .then(|| commit.ops.first())
14147                        .flatten()
14148                        .map(CommitOperation::from),
14149                };
14150                self.persist_commit_outcome(
14151                    &commit.client_commit_id,
14152                    CommitOutcomeStatus::Rejected,
14153                    &[CommitOperationOutcome::Error {
14154                        rejection: rejection.clone(),
14155                    }],
14156                    representable.then_some(commit.ops.as_slice()),
14157                )?;
14158                self.delete_outbox_persisted(&commit.client_commit_id)?;
14159                self.outbox
14160                    .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
14161                self.rejections.push(rejection);
14162                for operation in &commit.ops {
14163                    domains.table(operation.table.as_str());
14164                }
14165            }
14166            domains.status = true;
14167            domains.rejections = true;
14168            domains.outcomes = true;
14169            self.prune_commit_outcomes()?;
14170            // The visible projection is re-derived from the surviving base + outbox.
14171            self.overlay_dirty.set(true);
14172            self.rebuild_overlay()?;
14173            Ok(())
14174        })();
14175        let result = match batch {
14176            Some(batch) => {
14177                if dropped.is_ok() {
14178                    for table in domains.tables.keys() {
14179                        batch.table(table.as_str());
14180                    }
14181                    batch.status |= domains.status;
14182                    batch.rejections |= domains.rejections;
14183                    batch.outcomes |= domains.outcomes;
14184                }
14185                dropped
14186            }
14187            None => {
14188                dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
14189            }
14190        };
14191        if let Err(error) = result {
14192            if owns_observation {
14193                if owns_transaction {
14194                    if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14195                        let mut failure = self.storage_failure.borrow_mut();
14196                        let failure =
14197                            failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14198                        failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14199                    }
14200                } else {
14201                    self.rollback_observation("syncular_value_recovery");
14202                }
14203            }
14204            self.outbox = prior_outbox;
14205            self.rejections = prior_rejections;
14206            self.failed_commits = prior_failed_commits;
14207            self.change_queue.truncate(prior_changes);
14208            self.sync_intent_queue.truncate(prior_intents);
14209            self.last_change = prior_last_change;
14210            self.overlay_dirty.restore(prior_overlay_dirty);
14211            return Err(error);
14212        }
14213        Ok(true)
14214    }
14215
14216    pub fn pending_commit_ids(&self) -> Vec<String> {
14217        self.outbox
14218            .iter()
14219            .map(|c| c.client_commit_id.clone())
14220            .collect()
14221    }
14222
14223    /// §6.1: the sparse push payload bytes of every pending upsert operation,
14224    /// FIFO — exactly what the next push round would put on the wire. Used by
14225    /// conformance B.21(h) to pin cross-core byte identity.
14226    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
14227        let mut payloads = Vec::new();
14228        for commit in &self.outbox {
14229            for op in &commit.ops {
14230                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
14231                    continue;
14232                };
14233                let Some(table) = self.schema.table(&op.table) else {
14234                    continue;
14235                };
14236                let fallback = self.stored_key_fallback(table, &op.row_id);
14237                if let Ok(payload) = encode_sparse_row_json(
14238                    table,
14239                    &op.row_id,
14240                    values,
14241                    &self.encryption,
14242                    fallback.as_ref(),
14243                ) {
14244                    payloads.push(payload);
14245                }
14246            }
14247        }
14248        payloads
14249    }
14250
14251    pub fn conflicts(&self) -> &[ConflictRecord] {
14252        &self.conflicts
14253    }
14254
14255    pub fn rejections(&self) -> &[RejectionRecord] {
14256        &self.rejections
14257    }
14258
14259    pub fn resolve_commit_outcome(
14260        &mut self,
14261        input: ResolveCommitOutcomeInput,
14262    ) -> Result<CommitOutcome, String> {
14263        let current = self
14264            .commit_outcome(&input.client_commit_id)?
14265            .ok_or_else(|| {
14266                format!(
14267                    "sync.outcome_not_found: no durable outcome exists for {:?}",
14268                    input.client_commit_id
14269                )
14270            })?;
14271        if current.resolution != CommitOutcomeResolution::Active {
14272            return Ok(current);
14273        }
14274        if input.resolution == CommitOutcomeResolution::Active {
14275            return Err("sync.invalid_request: resolution must leave active state".to_owned());
14276        }
14277        match input.resolution {
14278            CommitOutcomeResolution::Superseded => {
14279                let replacement = input
14280                    .replacement_client_commit_id
14281                    .as_deref()
14282                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
14283                    .ok_or_else(|| {
14284                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
14285                            .to_owned()
14286                    })?;
14287                let _ = replacement;
14288            }
14289            _ if input.replacement_client_commit_id.is_some() => {
14290                return Err(
14291                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
14292                        .to_owned(),
14293                );
14294            }
14295            _ => {}
14296        }
14297        let allowed = match current.status {
14298            CommitOutcomeStatus::Conflict => matches!(
14299                input.resolution,
14300                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
14301            ),
14302            CommitOutcomeStatus::Rejected => {
14303                matches!(
14304                    input.resolution,
14305                    CommitOutcomeResolution::Superseded
14306                        | CommitOutcomeResolution::ResolvedKeepServer
14307                )
14308            }
14309            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
14310                input.resolution == CommitOutcomeResolution::Dismissed
14311            }
14312        };
14313        if !allowed {
14314            return Err(format!(
14315                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
14316                input.resolution, current.status
14317            ));
14318        }
14319
14320        self.begin_observation("syncular_outcome_resolution")?;
14321        let previous_failed = self.failed_commits.clone();
14322        let previous_conflicts = self.conflicts.clone();
14323        let previous_rejections = self.rejections.clone();
14324        let result = (|| {
14325            self.conn
14326                .execute(
14327                    "UPDATE _syncular_commit_outcomes
14328                        SET resolution = ?1, resolved_at_ms = ?2,
14329                            replacement_client_commit_id = ?3
14330                      WHERE client_commit_id = ?4 AND resolution = 'active'",
14331                    rusqlite::params![
14332                        Self::outcome_resolution_name(input.resolution),
14333                        self.clock_now_ms(),
14334                        input.replacement_client_commit_id,
14335                        input.client_commit_id
14336                    ],
14337                )
14338                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14339            let resolved = self
14340                .commit_outcome(&current.client_commit_id)?
14341                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
14342            self.conn
14343                .execute(
14344                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14345                    rusqlite::params![input.client_commit_id],
14346                )
14347                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14348            for (commit, _) in &self.failed_commits {
14349                if commit.client_commit_id == input.client_commit_id {
14350                    for operation in &commit.ops {
14351                        self.overlay_dirty.table(&operation.table);
14352                    }
14353                }
14354            }
14355            self.failed_commits
14356                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
14357            self.prune_commit_outcomes()?;
14358            Ok(resolved)
14359        })();
14360        let resolved = match result {
14361            Ok(outcome) => outcome,
14362            Err(error) => {
14363                self.rollback_observation("syncular_outcome_resolution");
14364                self.failed_commits = previous_failed;
14365                self.conflicts = previous_conflicts;
14366                self.rejections = previous_rejections;
14367                return Err(error);
14368            }
14369        };
14370        self.conflicts
14371            .retain(|record| record.client_commit_id != current.client_commit_id);
14372        self.rejections
14373            .retain(|record| record.client_commit_id != current.client_commit_id);
14374        let mut batch = ChangeAccumulator {
14375            conflicts: current.status == CommitOutcomeStatus::Conflict,
14376            rejections: current.status == CommitOutcomeStatus::Rejected,
14377            outcomes: true,
14378            ..ChangeAccumulator::default()
14379        };
14380        if let Some(operations) = &current.operations {
14381            for operation in operations {
14382                batch.table(&operation.table);
14383            }
14384        }
14385        if let Err(error) = self
14386            .rebuild_overlay_if_dirty()
14387            .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
14388        {
14389            self.rollback_observation("syncular_outcome_resolution");
14390            self.failed_commits = previous_failed;
14391            self.conflicts = previous_conflicts;
14392            self.rejections = previous_rejections;
14393            return Err(error);
14394        }
14395        Ok(resolved)
14396    }
14397
14398    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
14399        self.schema_floor.as_ref()
14400    }
14401
14402    /// §7.3.5: the client's opaque auth-lease state, if any.
14403    pub fn lease_state(&self) -> Option<&LeaseState> {
14404        self.lease_state.as_ref()
14405    }
14406
14407    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
14408    /// the two lease codes set it; other errors leave leaseState untouched.
14409    fn record_lease_error(&mut self, code: &str) {
14410        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
14411            return;
14412        }
14413        let mut next = self.lease_state.clone().unwrap_or_default();
14414        next.error_code = Some(code.to_owned());
14415        self.set_lease_state(Some(next));
14416    }
14417
14418    fn set_lease_state(&mut self, next: Option<LeaseState>) {
14419        if self.lease_state == next {
14420            return;
14421        }
14422        if self.begin_observation("syncular_lease").is_err() {
14423            return;
14424        }
14425        self.lease_state = next;
14426        if let Some(lease) = &self.lease_state {
14427            if let Ok(json) = serde_json::to_string(lease) {
14428                self.set_meta(LEASE_STATE_KEY, &json);
14429            }
14430        } else {
14431            self.delete_meta(LEASE_STATE_KEY);
14432        }
14433        let batch = ChangeAccumulator {
14434            status: true,
14435            ..ChangeAccumulator::default()
14436        };
14437        if self.finish_observation("syncular_lease", batch).is_err() {
14438            self.rollback_observation("syncular_lease");
14439        }
14440    }
14441
14442    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
14443        if self.schema_floor == next {
14444            return;
14445        }
14446        if self.begin_observation("syncular_schema_floor").is_err() {
14447            return;
14448        }
14449        self.schema_floor = next;
14450        self.stopped = self.schema_floor.is_some();
14451        if let Some(floor) = &self.schema_floor {
14452            if let Ok(json) = serde_json::to_string(floor) {
14453                self.set_meta(SCHEMA_FLOOR_KEY, &json);
14454            }
14455        } else {
14456            self.delete_meta(SCHEMA_FLOOR_KEY);
14457        }
14458        let batch = ChangeAccumulator {
14459            status: true,
14460            ..ChangeAccumulator::default()
14461        };
14462        if self
14463            .finish_observation("syncular_schema_floor", batch)
14464            .is_err()
14465        {
14466            self.rollback_observation("syncular_schema_floor");
14467        }
14468    }
14469
14470    fn set_upgrading(&mut self, value: bool) {
14471        if self.upgrading == value {
14472            return;
14473        }
14474        if self.begin_observation("syncular_upgrading").is_err() {
14475            return;
14476        }
14477        self.upgrading = value;
14478        let batch = ChangeAccumulator {
14479            status: true,
14480            ..ChangeAccumulator::default()
14481        };
14482        if self
14483            .finish_observation("syncular_upgrading", batch)
14484            .is_err()
14485        {
14486            self.rollback_observation("syncular_upgrading");
14487        }
14488    }
14489
14490    pub fn sync_needed(&self) -> bool {
14491        self.sync_needed
14492    }
14493
14494    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
14495        let sub = self.subs.iter().find(|s| s.id == id)?;
14496        Some(SubscriptionStateView {
14497            id: sub.id.clone(),
14498            table: sub.table.clone(),
14499            status: sub.state.name().to_owned(),
14500            cursor: sub.cursor,
14501            has_resume_token: sub.bootstrap_state.is_some(),
14502            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14503            reason_code: sub.reason_code.clone(),
14504        })
14505    }
14506
14507    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
14508        let schema_table = self
14509            .schema
14510            .table(table)
14511            .ok_or_else(|| format!("unknown table {table:?}"))?;
14512        let sql = format!(
14513            "SELECT * FROM {} ORDER BY {} ASC",
14514            visible_table(table),
14515            quote_ident(&schema_table.primary_key)
14516        );
14517        let mut stmt = self
14518            .conn
14519            .prepare(&sql)
14520            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14521        let mut rows = stmt
14522            .query([])
14523            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14524        let mut out = Vec::new();
14525        while let Some(row) = rows
14526            .next()
14527            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14528        {
14529            let mut values = Map::new();
14530            for (i, column) in schema_table.columns.iter().enumerate() {
14531                let value = row
14532                    .get_ref(i)
14533                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14534                values.insert(column.name.clone(), sql_ref_to_json(column, value));
14535            }
14536            let version: i64 = row
14537                .get(schema_table.columns.len())
14538                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14539            let row_id = match values.get(&schema_table.primary_key) {
14540                Some(Value::String(s)) => s.clone(),
14541                Some(Value::Number(n)) => n.to_string(),
14542                Some(Value::Bool(b)) => b.to_string(),
14543                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
14544            };
14545            out.push(RowState {
14546                row_id,
14547                version,
14548                values,
14549            });
14550        }
14551        Ok(out)
14552    }
14553
14554    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
14555    //
14556    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
14557    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
14558    // the op with `yrs`, re-encodes the whole doc state, and records a
14559    // baseVersion-less sparse upsert carrying only the primary key and the
14560    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
14561    // the overlay re-materializes the edit immediately (optimistic apply,
14562    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
14563    // Byte-compatible with `@syncular/crdt-yjs`.
14564
14565    /// The current stored value of a `crdt` column for one row — the visible
14566    /// (optimistic) bytes, or `None` when the row is absent or the column is
14567    /// NULL (the empty document, §5.10.1). Errors if the column is not a
14568    /// `crdt` column (guards the app against a typo'd column name).
14569    #[cfg(feature = "crdt-yjs")]
14570    fn crdt_column_bytes(
14571        &self,
14572        table: &str,
14573        row_id: &str,
14574        column: &str,
14575    ) -> Result<Option<Vec<u8>>, String> {
14576        let schema_table = self
14577            .schema
14578            .table(table)
14579            .ok_or_else(|| format!("unknown table {table:?}"))?;
14580        let col = schema_table
14581            .columns
14582            .iter()
14583            .find(|c| c.name == column)
14584            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
14585        if col.ty != ColumnType::Crdt {
14586            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
14587        }
14588        let sql = format!(
14589            "SELECT {} FROM {} WHERE {}",
14590            quote_ident(column),
14591            visible_table(table),
14592            row_id_predicate(schema_table)
14593        );
14594        let bytes: Option<Vec<u8>> = self
14595            .conn
14596            .prepare_cached(&sql)
14597            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14598            .query_row(rusqlite::params![row_id], |row| {
14599                row.get::<_, Option<Vec<u8>>>(0)
14600            })
14601            .map_err(|e| match e {
14602                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
14603                other => Self::sqlite_failure(&self.storage_failure, other),
14604            })?;
14605        Ok(bytes)
14606    }
14607
14608    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
14609    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
14610    /// An absent row / NULL column is the empty document (empty string).
14611    #[cfg(feature = "crdt-yjs")]
14612    pub fn crdt_text(
14613        &self,
14614        table: &str,
14615        row_id: &str,
14616        column: &str,
14617        name: &str,
14618    ) -> Result<String, String> {
14619        let bytes = self
14620            .crdt_column_bytes(table, row_id, column)?
14621            .unwrap_or_default();
14622        crate::crdt::text(&bytes, name)
14623    }
14624
14625    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
14626    /// record the resulting crdt-only sparse operation. Returns the enqueued
14627    /// `clientCommitId`.
14628    #[cfg(feature = "crdt-yjs")]
14629    pub fn crdt_insert_text(
14630        &mut self,
14631        table: &str,
14632        row_id: &str,
14633        column: &str,
14634        name: &str,
14635        index: u32,
14636        value: &str,
14637    ) -> Result<String, ClientError> {
14638        let prior = self.storage_failure.borrow_mut().take();
14639        let outcome = (|| -> Result<String, String> {
14640            let current = self
14641                .crdt_column_bytes(table, row_id, column)?
14642                .unwrap_or_default();
14643            let update = crate::crdt::insert_text(&current, name, index, value)?;
14644            self.perform_crdt_push_update(table, row_id, column, &update)
14645        })();
14646        self.finish_authoring(prior, outcome)
14647    }
14648
14649    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
14650    /// record the resulting crdt-only sparse operation. Returns the enqueued
14651    /// `clientCommitId`.
14652    #[cfg(feature = "crdt-yjs")]
14653    pub fn crdt_delete_text(
14654        &mut self,
14655        table: &str,
14656        row_id: &str,
14657        column: &str,
14658        name: &str,
14659        index: u32,
14660        len: u32,
14661    ) -> Result<String, ClientError> {
14662        let prior = self.storage_failure.borrow_mut().take();
14663        let outcome = (|| -> Result<String, String> {
14664            let current = self
14665                .crdt_column_bytes(table, row_id, column)?
14666                .unwrap_or_default();
14667            let update = crate::crdt::delete_text(&current, name, index, len)?;
14668            self.perform_crdt_push_update(table, row_id, column, &update)
14669        })();
14670        self.finish_authoring(prior, outcome)
14671    }
14672
14673    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
14674    /// `crdt` column's current state and record the resulting crdt-only sparse
14675    /// operation. The app authored the update with its own `yrs` model.
14676    /// Returns the enqueued `clientCommitId`.
14677    #[cfg(feature = "crdt-yjs")]
14678    pub fn crdt_apply_update(
14679        &mut self,
14680        table: &str,
14681        row_id: &str,
14682        column: &str,
14683        update: &[u8],
14684    ) -> Result<String, ClientError> {
14685        let prior = self.storage_failure.borrow_mut().take();
14686        let outcome = (|| -> Result<String, String> {
14687            let current = self
14688                .crdt_column_bytes(table, row_id, column)?
14689                .unwrap_or_default();
14690            let next = crate::crdt::apply_update(&current, update)?;
14691            self.perform_crdt_push_update(table, row_id, column, &next)
14692        })();
14693        self.finish_authoring(prior, outcome)
14694    }
14695
14696    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
14697    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
14698    /// enqueue it. The server merges the crdt column and leaves every other
14699    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
14700    /// clobber a concurrent edit to another column. A locally absent row
14701    /// records the same partial operation; the server answers per §6.2.
14702    #[cfg(feature = "crdt-yjs")]
14703    fn perform_crdt_push_update(
14704        &mut self,
14705        table: &str,
14706        row_id: &str,
14707        column: &str,
14708        crdt_bytes: &[u8],
14709    ) -> Result<String, String> {
14710        let mut bytes_obj = Map::new();
14711        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
14712        let mut partial = Map::new();
14713        partial.insert(column.to_owned(), Value::Object(bytes_obj));
14714        self.perform_patch(table, row_id, partial, None)
14715    }
14716
14717    /// Run an arbitrary read-only SQL query against the local database and
14718    /// return each row as a `column-name → JSON value` map. This is the seam
14719    /// the React `useSyncQuery` live-query API needs (it takes app-authored
14720    /// SQL over the visible tables/views, not a fixed table read like
14721    /// [`read_rows`]).
14722    ///
14723    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
14724    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
14725    /// same envelope the command surface uses everywhere else. Output BLOB
14726    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
14727    ///
14728    /// The result column typing is dynamic (SQLite's stored affinity), because
14729    /// arbitrary SQL can alias, join, and compute — there is no schema column
14730    /// to consult per output cell, unlike [`read_rows`].
14731    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
14732        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
14733    }
14734
14735    /// Repository benchmark and conformance access, present only with `bench-internals`.
14736    #[cfg(feature = "bench-internals")]
14737    #[doc(hidden)]
14738    pub fn benchmark_connection(&mut self) -> &mut Connection {
14739        &mut self.conn
14740    }
14741
14742    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
14743    #[cfg(feature = "bench-internals")]
14744    #[doc(hidden)]
14745    pub fn benchmark_phases(
14746        &self,
14747        enabled: Option<bool>,
14748        reset: bool,
14749    ) -> Result<Option<Value>, String> {
14750        self.benchmark_phases.configure(enabled, reset)?;
14751        Ok(self.benchmark_phases.snapshot())
14752    }
14753
14754    /// One serialized, read-only authority observation, including durable coverage.
14755    pub fn authority_snapshot(&self) -> Result<Value, String> {
14756        validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
14757        if self.limits.authority_reads.is_empty() {
14758            return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
14759        }
14760        self.conn
14761            .execute_batch("SAVEPOINT authority_snapshot")
14762            .map_err(|e| e.to_string())?;
14763        let result = (|| {
14764            let revision = self
14765                .get_meta(LOCAL_REVISION_KEY)
14766                .ok_or("sync.local_corrupt: authority revision is missing")?;
14767            let parsed_revision = revision
14768                .parse::<u64>()
14769                .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
14770            if parsed_revision.to_string() != revision {
14771                return Err("sync.local_corrupt: authority revision is invalid".into());
14772            }
14773            let mut tables = Vec::new();
14774            for read in &self.limits.authority_reads {
14775                let table = self
14776                    .schema
14777                    .table(&read.table)
14778                    .ok_or("client.authority_read_forbidden: authority table is not declared")?;
14779                let scalar = |column: &str, value: &Value| -> Result<String, String> {
14780                    let ty = table
14781                        .columns
14782                        .iter()
14783                        .find(|c| c.name == column)
14784                        .expect("validated authority column")
14785                        .ty;
14786                    if ty == ColumnType::Boolean {
14787                        if let Some(value) = value.as_bool() {
14788                            return Ok(value.to_string());
14789                        }
14790                        return match value.as_i64() {
14791                            Some(0) => Ok("false".into()),
14792                            Some(1) => Ok("true".into()),
14793                            _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
14794                        };
14795                    }
14796                    match value {
14797                        Value::String(s) => Ok(s.clone()),
14798                        Value::Number(n) => Ok(n.to_string()),
14799                        Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
14800                            .get("$bigint")
14801                            .and_then(Value::as_str)
14802                            .map(str::to_owned)
14803                            .ok_or("sync.local_corrupt: authority integer is invalid".into()),
14804                        _ => {
14805                            Err("sync.local_corrupt: authority scalar is missing or invalid".into())
14806                        }
14807                    }
14808                };
14809                let matches = |row: &QueryRow| -> Result<bool, String> {
14810                    for (variable, values) in &read.scopes {
14811                        let column = table
14812                            .scope_column(variable)
14813                            .expect("validated authority scope");
14814                        let value = row
14815                            .get(column)
14816                            .ok_or("sync.local_corrupt: authority scope column is missing")?;
14817                        if value.is_null() || !values.contains(&scalar(column, value)?) {
14818                            return Ok(false);
14819                        }
14820                    }
14821                    Ok(true)
14822                };
14823                let selected = read
14824                    .columns
14825                    .iter()
14826                    .map(|c| quote_ident(c))
14827                    .collect::<Vec<_>>()
14828                    .join(",");
14829                let mut intent = BTreeSet::new();
14830                let mut relevant = BTreeSet::new();
14831                for row in query_connection(&self.conn,
14832                    "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=?",
14833                    &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
14834                    let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
14835                    intent.insert(id.clone());
14836                    if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
14837                        let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
14838                        if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
14839                    }
14840                }
14841                let mut rows = BTreeMap::new();
14842                for full in [base_table(&read.table), visible_table(&read.table)] {
14843                    for mut values in query_connection(
14844                        &self.conn,
14845                        &format!(
14846                            "SELECT {selected},_syncular_version AS authority_version FROM {full}"
14847                        ),
14848                        &[],
14849                    )
14850                    .map_err(|e| e.to_string())?
14851                    {
14852                        if !matches(&values)? {
14853                            continue;
14854                        }
14855                        let id = scalar(
14856                            &table.primary_key,
14857                            values
14858                                .get(&table.primary_key)
14859                                .expect("selected primary key"),
14860                        )?;
14861                        relevant.insert(id.clone());
14862                        if full == base_table(&read.table) {
14863                            let version = values
14864                                .remove("authority_version")
14865                                .expect("selected version");
14866                            let version = version
14867                                .as_i64()
14868                                .ok_or("sync.local_corrupt: authority server version is invalid")?;
14869                            if version >= 0 {
14870                                rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
14871                            }
14872                        }
14873                    }
14874                }
14875                intent.retain(|id| relevant.contains(id));
14876                let rows = rows.into_values().collect::<Vec<_>>();
14877                let mut persisted = Vec::new();
14878                for row in query_connection(
14879                    &self.conn,
14880                    "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
14881                    &[Value::String(read.table.clone())],
14882                )
14883                .map_err(|e| e.message)?
14884                {
14885                    let state: Value = serde_json::from_str(
14886                        row.get("state_json")
14887                            .and_then(Value::as_str)
14888                            .ok_or("sync.local_corrupt: authority subscription state is missing")?,
14889                    )
14890                    .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
14891                    let effective = state.get("effectiveScopes").and_then(Value::as_object);
14892                    let status = state
14893                        .get("status")
14894                        .and_then(Value::as_str)
14895                        .ok_or("sync.local_corrupt: authority subscription status is missing")?;
14896                    let cursor = state
14897                        .get("cursor")
14898                        .and_then(Value::as_i64)
14899                        .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
14900                    let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
14901                        && status == "active"
14902                        && cursor >= 0
14903                        && state.get("bootstrapState").is_none_or(Value::is_null)
14904                        && state.get("params").is_none_or(Value::is_null);
14905                    let exposed = effective.map(|s| {
14906                        read.scopes
14907                            .keys()
14908                            .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
14909                            .collect::<Map<_, _>>()
14910                    });
14911                    let requested = read
14912                        .scopes
14913                        .keys()
14914                        .map(|v| {
14915                            (
14916                                v.clone(),
14917                                state
14918                                    .get("requested")
14919                                    .and_then(|s| s.get(v))
14920                                    .cloned()
14921                                    .unwrap_or(json!([])),
14922                            )
14923                        })
14924                        .collect::<Map<_, _>>();
14925                    persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
14926                }
14927                fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
14928                    match selectors.split_first() {
14929                        None => !candidates.is_empty(),
14930                        Some(((variable, values), rest)) => values.iter().all(|value| {
14931                            covered(
14932                                rest,
14933                                &candidates
14934                                    .iter()
14935                                    .copied()
14936                                    .filter(|p| {
14937                                        p["effectiveScopes"][variable.as_str()]
14938                                            .as_array()
14939                                            .is_some_and(|held| {
14940                                                held.contains(&Value::String(value.clone()))
14941                                                    || held.contains(&Value::String("*".into()))
14942                                            })
14943                                    })
14944                                    .collect::<Vec<_>>(),
14945                            )
14946                        }),
14947                    }
14948                }
14949                let coverage = if covered(
14950                    &read.scopes.iter().collect::<Vec<_>>(),
14951                    &persisted
14952                        .iter()
14953                        .filter(|p| p["complete"] == true)
14954                        .collect::<Vec<_>>(),
14955                ) {
14956                    "complete"
14957                } else if persisted.iter().any(|p| {
14958                    p["status"] == "active"
14959                        && read.scopes.iter().all(|(v, values)| {
14960                            values.iter().any(|value| {
14961                                p["requestedScopes"][v].as_array().is_some_and(|held| {
14962                                    held.contains(&Value::String(value.clone()))
14963                                        || held.contains(&Value::String("*".into()))
14964                                })
14965                            })
14966                        })
14967                }) {
14968                    "pending"
14969                } else {
14970                    "missing"
14971                };
14972                tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
14973            }
14974            Ok(
14975                json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
14976            )
14977        })();
14978        match result {
14979            Ok(snapshot) => {
14980                self.conn
14981                    .execute_batch("RELEASE authority_snapshot")
14982                    .map_err(|e| e.to_string())?;
14983                Ok(snapshot)
14984            }
14985            Err(error) => {
14986                self.conn
14987                    .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
14988                    .map_err(|e| format!("{error}; rollback failed: {e}"))?;
14989                Err(error)
14990            }
14991        }
14992    }
14993
14994    /// Rows, coverage, and local revision from one SQLite read snapshot.
14995    /// §7.5 atomic snapshot read. An `owner` records a failure in
14996    /// `diagnostics_snapshot().query_failures` until its next successful read.
14997    pub fn query_snapshot(
14998        &mut self,
14999        sql: &str,
15000        params: &[QueryValue],
15001        coverage: &[WindowCoverage],
15002        owner: Option<QueryOwner<'_>>,
15003    ) -> Result<QuerySnapshot, String> {
15004        let result = snapshot_connection(&self.conn, sql, params, coverage);
15005        if let Some(owner) = owner {
15006            self.record_query_read(owner, result.as_ref().err());
15007        }
15008        result.map_err(|failure| failure.to_string())
15009    }
15010
15011    /// §7.6: record the outcome of one owned snapshot read, including reads a
15012    /// host ran on a separate connection. A success removes the owner's entry;
15013    /// an identical failure leaves it unchanged; a changed failure moves last.
15014    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
15015        let position = self
15016            .query_failures
15017            .iter()
15018            .position(|entry| entry.id == owner.id);
15019        let Some(failure) = failure else {
15020            if let Some(index) = position {
15021                self.query_failures.remove(index);
15022            }
15023            return;
15024        };
15025        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
15026            .into_iter()
15027            .map(str::to_owned)
15028            .collect();
15029        let code = failure.code.unwrap_or("client.query_failed").to_owned();
15030        if position.is_some_and(|index| {
15031            let entry = &self.query_failures[index];
15032            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
15033        }) {
15034            return;
15035        }
15036        if let Some(index) = position {
15037            self.query_failures.remove(index);
15038        }
15039        self.query_failures.push(DiagnosticQueryFailure {
15040            id: owner.id.to_owned(),
15041            tables,
15042            code,
15043            sqlite_code: failure.sqlite_code,
15044            at_ms: self.clock_now_ms(),
15045        });
15046        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
15047            self.query_failures.remove(0);
15048        }
15049    }
15050
15051    // -- request building ---------------------------------------------------------
15052
15053    fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
15054        #[cfg(feature = "bench-internals")]
15055        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
15056        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
15057        let max_commits = self.limits.max_push_commits_per_request;
15058        let max_ops = self
15059            .limits
15060            .max_push_operations_per_request
15061            .unwrap_or(PUSH_OPS_PER_REQUEST);
15062        let byte_cap = self.limits.max_push_request_bytes;
15063        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
15064        // bare URL — capability negotiation, decided per transport.
15065        let accept = self.limits.accept.unwrap_or(if url_capable {
15066            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
15067        } else {
15068            DEFAULT_ACCEPT
15069        });
15070        let header = Frame::ReqHeader {
15071            client_id: self.client_id.clone(),
15072            schema_version: self.schema.version,
15073            log_epoch: log_epoch.clone(),
15074        };
15075        let pull = Frame::PullHeader {
15076            limit_commits: self.limits.limit_commits.unwrap_or(0),
15077            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
15078            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
15079            accept,
15080        };
15081        let mut fresh = Vec::new();
15082        let mut sub_frames = Vec::new();
15083        for sub in &self.subs {
15084            if sub.state != SubState::Active {
15085                continue;
15086            }
15087            let mut scopes = sub.requested.clone();
15088            sort_scope_map(&mut scopes);
15089            sub_frames.push(Frame::Subscription {
15090                id: sub.id.clone(),
15091                table: sub.table.clone(),
15092                scopes,
15093                params: sub.params.clone().map(RawJson),
15094                cursor: sub.cursor,
15095                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
15096            });
15097            fresh.push((
15098                sub.id.clone(),
15099                sub.cursor < 0 && sub.bootstrap_state.is_none(),
15100            ));
15101        }
15102        // The fixed frames (order-independent size) and the header+pull probe
15103        // both encode canonically, so the byte budget is the exact wire size.
15104        let fixed_bytes = match byte_cap {
15105            Some(_) => {
15106                let mut frames = vec![header.clone(), pull.clone()];
15107                frames.extend(sub_frames.iter().cloned());
15108                encode_message(&Message {
15109                    wire_version: WIRE_VERSION,
15110                    msg_kind: MsgKind::Request,
15111                    frames,
15112                })
15113                .len()
15114            }
15115            None => 0,
15116        };
15117        if let Some(cap) = byte_cap {
15118            if fixed_bytes > cap {
15119                return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
15120            }
15121        }
15122        let base_bytes = match byte_cap {
15123            Some(_) => encode_message(&Message {
15124                wire_version: WIRE_VERSION,
15125                msg_kind: MsgKind::Request,
15126                frames: vec![header.clone(), pull.clone()],
15127            })
15128            .len(),
15129            None => 0,
15130        };
15131        let total_outbox = if log_epoch.is_some() {
15132            self.outbox.len()
15133        } else {
15134            0
15135        };
15136        let mut push_frames: Vec<Frame> = Vec::new();
15137        let mut pushed_ids = Vec::new();
15138        let mut ops_in_request = 0usize;
15139        let mut bytes_in_request = 0usize;
15140        let mut deferred_commits = 0usize;
15141        #[cfg(feature = "bench-internals")]
15142        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
15143        for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
15144            // §6.1/§7.1: the commit-count and operation caps are hard. A
15145            // first commit over the operation cap is a typed capacity error
15146            // and stays queued; a later one defers with the complete suffix.
15147            if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
15148                deferred_commits = total_outbox - index;
15149                break;
15150            }
15151            if ops_in_request + commit.ops.len() > max_ops {
15152                if push_frames.is_empty() {
15153                    return Err(Self::push_capacity_outcome(
15154                        "operations",
15155                        max_ops,
15156                        commit.ops.len(),
15157                        Some(commit.client_commit_id.as_str()),
15158                    ));
15159                }
15160                deferred_commits = total_outbox - index;
15161                break;
15162            }
15163            let operations = commit
15164                .ops
15165                .iter()
15166                .map(|op| {
15167                    let payload = op.values.as_ref().and_then(|values| {
15168                        let table = self.schema.table(&op.table)?;
15169                        // §0: outbox entries encode at send time with the
15170                        // current codec (validated at mutate()). §6.1: the
15171                        // values map's keys are the sparse presence set.
15172                        // §5.11: encrypted columns are encrypted here, with
15173                        // an absent key-id selector read from the stored row.
15174                        // `sync_inner` runs `drop_unencodable_outbox` first,
15175                        // so an encrypt failure cannot reach this encode; the
15176                        // author seam validated every other failure.
15177                        let fallback = self.stored_key_fallback(table, &op.row_id);
15178                        encode_sparse_row_json(
15179                            table,
15180                            &op.row_id,
15181                            values,
15182                            &self.encryption,
15183                            fallback.as_ref(),
15184                        )
15185                        .ok()
15186                    });
15187                    ssp2::model::Operation {
15188                        table: op.table.clone(),
15189                        row_id: op.row_id.clone(),
15190                        op: if op.upsert { Op::Upsert } else { Op::Delete },
15191                        base_version: op.base_version,
15192                        payload,
15193                    }
15194                })
15195                .collect();
15196            let frame = Frame::PushCommit {
15197                client_commit_id: commit.client_commit_id.clone(),
15198                operations,
15199            };
15200            let frame_bytes = match byte_cap {
15201                Some(_) => {
15202                    encode_message(&Message {
15203                        wire_version: WIRE_VERSION,
15204                        msg_kind: MsgKind::Request,
15205                        frames: vec![header.clone(), frame.clone(), pull.clone()],
15206                    })
15207                    .len()
15208                        - base_bytes
15209                }
15210                None => 0,
15211            };
15212            // The head is always encoded so the caller can report the full
15213            // projected request size; a later commit defers once it no longer
15214            // fits.
15215            if let Some(cap) = byte_cap {
15216                if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
15217                    deferred_commits = total_outbox - index;
15218                    break;
15219                }
15220            }
15221            push_frames.push(frame);
15222            pushed_ids.push(commit.client_commit_id.clone());
15223            ops_in_request += commit.ops.len();
15224            bytes_in_request += frame_bytes;
15225        }
15226        #[cfg(feature = "bench-internals")]
15227        drop(outbox_phase);
15228        if let Some(cap) = byte_cap {
15229            if fixed_bytes + bytes_in_request > cap {
15230                // The head alone does not fit this budget; the commit stays
15231                // queued and no transport round is attempted.
15232                let blocked = if push_frames.is_empty() {
15233                    None
15234                } else {
15235                    self.outbox.first().map(|c| c.client_commit_id.as_str())
15236                };
15237                return Err(Self::push_capacity_outcome(
15238                    "bytes",
15239                    cap,
15240                    fixed_bytes + bytes_in_request,
15241                    blocked,
15242                ));
15243            }
15244        }
15245        let mut frames = vec![header];
15246        frames.extend(push_frames);
15247        frames.push(pull);
15248        frames.extend(sub_frames);
15249        let message = Message {
15250            wire_version: WIRE_VERSION,
15251            msg_kind: MsgKind::Request,
15252            frames,
15253        };
15254        Ok((
15255            message,
15256            RequestMeta {
15257                pushed_ids,
15258                fresh,
15259                accept,
15260                deferred_commits,
15261            },
15262        ))
15263    }
15264
15265    /// §7.1: a configured push capacity limit cannot carry this request.
15266    fn push_capacity_outcome(
15267        kind: &str,
15268        limit: usize,
15269        size: usize,
15270        client_commit_id: Option<&str>,
15271    ) -> Box<SyncOutcome> {
15272        let mut details = Map::new();
15273        details.insert("kind".to_owned(), Value::from(kind));
15274        details.insert("limit".to_owned(), Value::from(limit as u64));
15275        details.insert("size".to_owned(), Value::from(size as u64));
15276        if let Some(id) = client_commit_id {
15277            details.insert("clientCommitId".to_owned(), Value::from(id));
15278        }
15279        Box::new(SyncOutcome::Failed {
15280            error_code: "client.push_request_too_large".into(),
15281            message: "the push request exceeds a configured capacity limit".into(),
15282            details: Some(Value::Object(details)),
15283        })
15284    }
15285
15286    // -- sync -------------------------------------------------------------------
15287
15288    /// Cloneable observation handle; no client lock is needed to read it.
15289    pub fn progress(&self) -> ProgressObserver {
15290        self.progress.clone()
15291    }
15292
15293    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
15294        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
15295            Ok(prepared) => prepared,
15296            Err(outcome) => return *outcome,
15297        };
15298        // The round's network deadline rides `PreparedSyncRound` and is scoped
15299        // to each exchange, so uploads, continuations, the main request, and
15300        // every segment fetch share one monotonic anchor without leaving
15301        // deadline state on a reused transport.
15302        let mut next = prepared;
15303        loop {
15304            match self.apply_sync_round(next.exchange(transport)) {
15305                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
15306                crate::AppliedSyncRound::Complete {
15307                    outcome, controls, ..
15308                } => {
15309                    if self.transport_enabled {
15310                        for text in controls {
15311                            let _ = transport.realtime_send(&text);
15312                        }
15313                    } else {
15314                        self.disconnect_realtime(transport);
15315                    }
15316                    return outcome;
15317                }
15318            }
15319        }
15320    }
15321
15322    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
15323        if let SyncOutcome::Failed {
15324            error_code,
15325            message,
15326            details,
15327        } = &mut outcome
15328        {
15329            if let Some(failure) = self.storage_failure.borrow().as_ref() {
15330                let code = failure
15331                    .code
15332                    .expect("retained storage failures are classified");
15333                *error_code = code.to_owned();
15334                *message = match code {
15335                    "client.storage_busy" => "local SQLite storage is busy",
15336                    "client.storage_full" => "local SQLite storage is full",
15337                    "client.storage_io" => "local SQLite storage I/O failed",
15338                    "client.storage_corrupt" => "local SQLite storage is corrupt",
15339                    _ => unreachable!("unrecognized classified storage failure"),
15340                }
15341                .to_owned();
15342                *details = failure.details();
15343            }
15344        }
15345        let retry_delay_ms = self.round_retry_delay_ms;
15346        self.progress.update(|p| match &outcome {
15347            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
15348                if report.failed.is_empty() =>
15349            {
15350                p.state = ProgressState::Complete
15351            }
15352            SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
15353                p.state = ProgressState::Failed;
15354                p.error_code = Some("sync.scope_revoked".into());
15355            }
15356            SyncOutcome::Failed { error_code, .. } => {
15357                p.state = ProgressState::Failed;
15358                p.error_code = Some(Self::diagnostic_code(error_code));
15359                p.retry_delay_ms = retry_delay_ms;
15360            }
15361            SyncOutcome::RealtimeUnavailable { .. } => {
15362                p.state = ProgressState::Failed;
15363                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
15364                p.retry_delay_ms = retry_delay_ms;
15365            }
15366        });
15367        let completed_at_ms = self.clock_now_ms();
15368        self.last_round = Some(match &outcome {
15369            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
15370                status: "succeeded".to_owned(),
15371                started_at_ms,
15372                completed_at_ms,
15373                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15374                counters: Some(DiagnosticRoundCounters {
15375                    pushed: report.pushed,
15376                    applied: report.applied.len(),
15377                    rejected: report.rejected.len(),
15378                    retryable: report.retryable.len(),
15379                    conflicts: report.conflicts,
15380                    commits_applied: report.commits_applied,
15381                    segment_rows_applied: report.segment_rows_applied,
15382                    bootstrapping: report.bootstrapping.len(),
15383                    resets: report.resets.len(),
15384                    revoked: report.revoked.len(),
15385                    failed: report.failed.len(),
15386                    deferred_commits: report.deferred_commits,
15387                }),
15388                error_code: None,
15389            },
15390            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
15391                status: "failed".to_owned(),
15392                started_at_ms,
15393                completed_at_ms,
15394                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15395                counters: None,
15396                error_code: Some(Self::diagnostic_code(error_code)),
15397            },
15398            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
15399                status: "failed".to_owned(),
15400                started_at_ms,
15401                completed_at_ms,
15402                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15403                counters: None,
15404                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
15405            },
15406        });
15407        outcome
15408    }
15409
15410    /// Capture one request on the client owner. Mutations made after this
15411    /// call remain in the outbox for a later round; no network I/O occurs here.
15412    pub fn prepare_sync_round(
15413        &mut self,
15414        url_capable: bool,
15415    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
15416        if !self.transport_enabled {
15417            return Err(Box::new(SyncOutcome::Failed {
15418                error_code: "sync.offline".into(),
15419                message: "transport gate is closed".into(),
15420                details: None,
15421            }));
15422        }
15423        if self.active_round.is_some() {
15424            return Err(Box::new(SyncOutcome::Failed {
15425                error_code: "client.round_in_progress".into(),
15426                message: "a sync round is already in flight".into(),
15427                details: None,
15428            }));
15429        }
15430        self.storage_failure.borrow_mut().take();
15431        self.progress.start();
15432        self.round_retry_delay_ms = None;
15433        let started_at_ms = self.clock_now_ms();
15434        let prepared = (|| {
15435            if self.stopped {
15436                return Err(Box::new(SyncOutcome::Ok(SyncReport {
15437                    schema_floor: self.schema_floor.clone(),
15438                    ..SyncReport::default()
15439                })));
15440            }
15441            if self.security_preflight {
15442                return Err(Box::new(SyncOutcome::Failed {
15443                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
15444                    message: "security preflight must complete before syncing".into(),
15445                    details: None,
15446                }));
15447            }
15448            self.set_sync_needed(false, false);
15449            self.drain_pending_evictions()
15450                .and_then(|_| self.drop_incompatible_outbox())
15451                .and_then(|_| self.drop_unencodable_outbox(true, None))
15452                .map_err(|message| {
15453                    Box::new(SyncOutcome::Failed {
15454                        error_code: "storage.failed".into(),
15455                        message,
15456                        details: None,
15457                    })
15458                })?;
15459            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
15460                self.pending_blob_uploads().map_err(|e| {
15461                    Box::new(SyncOutcome::Failed {
15462                        error_code: e.code,
15463                        message: e.message,
15464                        details: e.details,
15465                    })
15466                })?
15467            } else {
15468                Vec::new()
15469            };
15470            if self.realtime_state != RealtimeState::Connected
15471                && self.realtime_policy == RealtimePolicy::Required
15472            {
15473                let retry_delay_ms = self.schedule_background_retry();
15474                self.realtime_retry_delay_ms = Some(retry_delay_ms);
15475                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
15476                    state: self.realtime_state,
15477                    reason_code: self.realtime_reason_code.clone(),
15478                    retry_delay_ms,
15479                }));
15480            }
15481            let (message, meta) = self.build_request(url_capable)?;
15482            let id = uuid::Uuid::new_v4();
15483            self.active_round = Some(id);
15484            Ok(crate::PreparedSyncRound {
15485                id,
15486                started_at_ms,
15487                message,
15488                meta,
15489                uploads: uploads.into(),
15490                realtime: self.realtime_state == RealtimeState::Connected,
15491                scopes: self
15492                    .subs
15493                    .iter()
15494                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
15495                    .collect(),
15496                #[cfg(feature = "bench-internals")]
15497                benchmark_phases: self.benchmark_phases.clone(),
15498                progress: self.progress.clone(),
15499                fixed_now: self.now_ms,
15500                round_deadline_at: None,
15501            })
15502        })();
15503        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
15504    }
15505
15506    /// Discard the reply of an old authorization/subscription context. Its
15507    /// submitted commits stay durable and can be retried with the same ids.
15508    pub fn cancel_sync_round(&mut self) {
15509        self.active_round = None;
15510    }
15511
15512    /// Apply captured network results on the owner, preserving all normal
15513    /// version, schema, content-address and cursor checks. No network I/O.
15514    pub fn apply_sync_round(
15515        &mut self,
15516        completed: crate::CompletedSyncRound,
15517    ) -> crate::AppliedSyncRound {
15518        let prepared = completed.prepared;
15519        if self.active_round != Some(prepared.id) || self.security_preflight {
15520            return crate::AppliedSyncRound::Complete {
15521                outcome: SyncOutcome::Failed {
15522                    error_code: "client.round_cancelled".into(),
15523                    message: "sync round belongs to a released client context".into(),
15524                    details: None,
15525                },
15526                controls: Vec::new(),
15527                more: false,
15528                bootstrap_advanced: false,
15529            };
15530        }
15531        self.storage_failure.borrow_mut().take();
15532        let (response, mut downloads, transport_failed) = match completed.exchange {
15533            crate::round::ExchangeResult::Upload { id, result } => {
15534                let result = result.and_then(|()| {
15535                    self.conn
15536                        .execute(
15537                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
15538                            rusqlite::params![id],
15539                        )
15540                        .map(|_| ())
15541                        .map_err(|error| {
15542                            TransportError::new(
15543                                "client.failed",
15544                                Self::sqlite_failure(&self.storage_failure, error),
15545                            )
15546                        })
15547                });
15548                match result {
15549                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
15550                    Err(TransportError {
15551                        code,
15552                        message,
15553                        details,
15554                    }) => {
15555                        self.active_round = None;
15556                        if Self::retryable_failure_code(&code) {
15557                            self.schedule_background_retry();
15558                        }
15559                        let outcome = self.finish_sync_round(
15560                            prepared.started_at_ms,
15561                            SyncOutcome::Failed {
15562                                error_code: code,
15563                                message,
15564                                details,
15565                            },
15566                        );
15567                        return crate::AppliedSyncRound::Complete {
15568                            outcome,
15569                            controls: Vec::new(),
15570                            more: false,
15571                            bootstrap_advanced: false,
15572                        };
15573                    }
15574                }
15575            }
15576            crate::round::ExchangeResult::Reply {
15577                response,
15578                downloads,
15579                transport_failed,
15580            } => (response, downloads, transport_failed),
15581        };
15582        self.active_round = None;
15583        let before = self
15584            .subs
15585            .iter()
15586            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
15587            .collect::<Vec<_>>();
15588        let outcome = (|| {
15589            let response = match response {
15590                Ok(response) => response,
15591                Err(TransportError {
15592                    code,
15593                    message,
15594                    details,
15595                }) => {
15596                    if transport_failed {
15597                        if prepared.realtime {
15598                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
15599                        }
15600                        self.record_lease_error(&code);
15601                    }
15602                    if Self::retryable_failure_code(&code) {
15603                        let delay = self.schedule_background_retry();
15604                        if prepared.realtime && transport_failed {
15605                            self.realtime_retry_delay_ms = Some(delay);
15606                        }
15607                    }
15608                    return SyncOutcome::Failed {
15609                        error_code: code,
15610                        message,
15611                        details,
15612                    };
15613                }
15614            };
15615            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
15616            if let SyncOutcome::Ok(report) = &mut outcome {
15617                report.deferred_commits = prepared.meta.deferred_commits;
15618            }
15619            match &outcome {
15620                SyncOutcome::Ok(_) => self.reset_background_retry(),
15621                SyncOutcome::Failed { error_code, .. }
15622                    if Self::retryable_failure_code(error_code) =>
15623                {
15624                    self.schedule_background_retry();
15625                }
15626                _ => {}
15627            }
15628            if prepared.meta.deferred_commits > 0 {
15629                self.set_sync_needed(true, true);
15630            }
15631            self.previous_version_lifetime_check();
15632            outcome
15633        })();
15634        let more = self.transport_enabled
15635            && matches!(&outcome, SyncOutcome::Ok(report)
15636                if report.schema_floor.is_none()
15637                    && (!report.bootstrapping.is_empty()
15638                        || report.commits_applied > 0
15639                        || report.segment_rows_applied > 0
15640                        || !report.resets.is_empty()
15641                        || report.deferred_commits > 0
15642                        || self.sync_needed));
15643        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
15644            && before
15645                != self
15646                    .subs
15647                    .iter()
15648                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
15649                    .collect::<Vec<_>>();
15650        crate::AppliedSyncRound::Complete {
15651            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
15652            controls: if self.transport_enabled {
15653                downloads.controls
15654            } else {
15655                Vec::new()
15656            },
15657            more,
15658            bootstrap_advanced,
15659        }
15660    }
15661
15662    pub fn sync_until_idle(
15663        &mut self,
15664        transport: &mut dyn Transport,
15665        max_rounds: Option<u32>,
15666    ) -> SyncOutcome {
15667        // §7.7: an explicit budget is a strict positive round count. Zero is a
15668        // caller error, not an exhaustion.
15669        if max_rounds == Some(0) {
15670            return SyncOutcome::Failed {
15671                details: None,
15672                error_code: "sync.invalid_request".into(),
15673                message: "maxRounds must be a positive integer".into(),
15674            };
15675        }
15676        let rounds = max_rounds.unwrap_or(20);
15677        let mut aggregate = SyncReport::default();
15678        let mut spent = 0;
15679        while spent < rounds {
15680            spent += 1;
15681            let before = self
15682                .subs
15683                .iter()
15684                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
15685                .collect::<Vec<_>>();
15686            match self.sync(transport) {
15687                SyncOutcome::Failed {
15688                    error_code,
15689                    message,
15690                    details,
15691                } => {
15692                    return SyncOutcome::Failed {
15693                        details,
15694                        error_code,
15695                        message,
15696                    };
15697                }
15698                outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
15699                SyncOutcome::BudgetExhausted(_) => {
15700                    unreachable!("sync() never exhausts a round budget")
15701                }
15702                SyncOutcome::Ok(report) => {
15703                    if max_rounds.is_none()
15704                        && report.segment_rows_applied > 0
15705                        && !report.bootstrapping.is_empty()
15706                        && before
15707                            != self
15708                                .subs
15709                                .iter()
15710                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
15711                                .collect::<Vec<_>>()
15712                    {
15713                        spent = 0;
15714                    }
15715                    // TS parity: a disabled transport or a schema floor ends
15716                    // the loop. The latest round still merges first.
15717                    let stop = !self.transport_enabled() || report.schema_floor.is_some();
15718                    aggregate.merge(&report);
15719                    if stop {
15720                        return SyncOutcome::Ok(aggregate);
15721                    }
15722                    // §4.5: pull again whenever the response contained
15723                    // commits or segments; resets re-bootstrap; a pending
15724                    // resume token continues paging (§4.7); deferred outbox
15725                    // commits and a raised sync-needed signal push on the next
15726                    // round.
15727                    let more = !report.bootstrapping.is_empty()
15728                        || report.commits_applied > 0
15729                        || report.segment_rows_applied > 0
15730                        || !report.resets.is_empty()
15731                        || report.deferred_commits > 0
15732                        || self.sync_needed;
15733                    if !more {
15734                        return SyncOutcome::Ok(aggregate);
15735                    }
15736                }
15737            }
15738        }
15739        // §7.7: partial success. The aggregate report is retained and the run
15740        // is explicitly not idle.
15741        SyncOutcome::BudgetExhausted(aggregate)
15742    }
15743
15744    fn process_response(
15745        &mut self,
15746        transport: &mut dyn Transport,
15747        response: Message,
15748        meta: &RequestMeta,
15749    ) -> SyncOutcome {
15750        #[cfg(feature = "bench-internals")]
15751        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
15752        let mut report = SyncReport {
15753            pushed: meta.pushed_ids.len() as u32,
15754            ..SyncReport::default()
15755        };
15756        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
15757            HashMap::new();
15758        let pushed_ids = meta
15759            .pushed_ids
15760            .iter()
15761            .map(String::as_str)
15762            .collect::<HashSet<_>>();
15763        let mut last_final_push_result_id: Option<String> = None;
15764        for frame in &response.frames {
15765            match frame {
15766                Frame::PushResultDetails {
15767                    client_commit_id,
15768                    entries,
15769                } => {
15770                    let details = rejection_details_by_commit
15771                        .entry(client_commit_id.clone())
15772                        .or_default();
15773                    for entry in entries {
15774                        let parsed = match RejectionDetails::parse(&entry.details.0) {
15775                            Ok(value) => value,
15776                            Err(message) => {
15777                                return SyncOutcome::Failed {
15778                                    details: None,
15779                                    error_code: "sync.invalid_request".to_owned(),
15780                                    message,
15781                                };
15782                            }
15783                        };
15784                        details.insert(entry.op_index, parsed);
15785                    }
15786                }
15787                Frame::PushResult {
15788                    client_commit_id,
15789                    status,
15790                    results,
15791                    ..
15792                } if pushed_ids.contains(client_commit_id.as_str())
15793                    && Self::push_result_is_final(*status, results) =>
15794                {
15795                    last_final_push_result_id = Some(client_commit_id.clone());
15796                }
15797                _ => {}
15798            }
15799        }
15800        let mut frames = response.frames.into_iter().peekable();
15801        if response.wire_version < 2 {
15802            return SyncOutcome::Failed {
15803                details: None,
15804                error_code: "client.invalid_host_response".to_owned(),
15805                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
15806            };
15807        }
15808        match frames.next() {
15809            Some(Frame::RespHeader {
15810                required_schema_version,
15811                latest_schema_version,
15812                log_epoch,
15813                reset_required,
15814            }) => {
15815                let Some(log_epoch) = log_epoch else {
15816                    return SyncOutcome::Failed {
15817                        details: None,
15818                        error_code: "client.invalid_host_response".to_owned(),
15819                        message: "response header omits logEpoch".to_owned(),
15820                    };
15821                };
15822                let Some(reset_required) = reset_required else {
15823                    return SyncOutcome::Failed {
15824                        details: None,
15825                        error_code: "client.invalid_host_response".to_owned(),
15826                        message: "response header omits resetRequired".to_owned(),
15827                    };
15828                };
15829                if let Some(required) = required_schema_version {
15830                    // §1.6 schema-floor response: nothing else is processed;
15831                    // stop syncing and surface the upgrade requirement.
15832                    let floor = SchemaFloor {
15833                        required_schema_version: Some(required),
15834                        latest_schema_version,
15835                    };
15836                    self.set_schema_floor(Some(floor.clone()));
15837                    report.schema_floor = Some(floor);
15838                    return SyncOutcome::Ok(report);
15839                }
15840                if reset_required {
15841                    if frames.next().is_some() {
15842                        return SyncOutcome::Failed {
15843                            details: None,
15844                            error_code: "client.invalid_host_response".to_owned(),
15845                            message: "log-epoch reset response contains body frames".to_owned(),
15846                        };
15847                    }
15848                    match self.run_log_epoch_reset(&log_epoch) {
15849                        Ok(resets) => {
15850                            report.resets = resets;
15851                            return SyncOutcome::Ok(report);
15852                        }
15853                        Err(message) => {
15854                            return SyncOutcome::Failed {
15855                                details: None,
15856                                error_code: "sync.local_corrupt".to_owned(),
15857                                message,
15858                            };
15859                        }
15860                    }
15861                }
15862                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
15863                    return SyncOutcome::Failed {
15864                        details: None,
15865                        error_code: "client.invalid_host_response".to_owned(),
15866                        message: "server changed logEpoch without requiring a reset".to_owned(),
15867                    };
15868                }
15869            }
15870            _ => {
15871                return SyncOutcome::Failed {
15872                    details: None,
15873                    error_code: "sync.invalid_request".to_owned(),
15874                    message: "response does not start with RESP_HEADER".to_owned(),
15875                };
15876            }
15877        }
15878
15879        let mut failure: Option<(String, String)> = None;
15880        while let Some(frame) = frames.next() {
15881            match frame {
15882                frame @ Frame::PushResult { .. } => {
15883                    let successful = matches!(
15884                        &frame,
15885                        Frame::PushResult {
15886                            status: PushStatus::Applied | PushStatus::Cached,
15887                            ..
15888                        }
15889                    );
15890                    let mut results = vec![frame];
15891                    if successful {
15892                        while matches!(
15893                            frames.peek(),
15894                            Some(Frame::PushResult {
15895                                status: PushStatus::Applied | PushStatus::Cached,
15896                                ..
15897                            })
15898                        ) {
15899                            if let Some(next) = frames.next() {
15900                                results.push(next);
15901                            }
15902                        }
15903                    }
15904                    if self
15905                        .handle_push_results(
15906                            &results,
15907                            &pushed_ids,
15908                            &rejection_details_by_commit,
15909                            &mut report,
15910                            last_final_push_result_id.as_deref(),
15911                        )
15912                        .is_err()
15913                    {
15914                        failure = Some((
15915                            "client.outcome_persistence_failed".to_owned(),
15916                            "local commit outcome could not be persisted".to_owned(),
15917                        ));
15918                        break;
15919                    }
15920                }
15921                Frame::PushResultDetails { .. } => {}
15922                Frame::SubStart {
15923                    id,
15924                    status,
15925                    reason_code,
15926                    effective_scopes,
15927                    bootstrap: _,
15928                } => {
15929                    let mut body = Vec::new();
15930                    let mut sub_end: Option<(i64, Option<String>)> = None;
15931                    for inner in frames.by_ref() {
15932                        match inner {
15933                            Frame::SubEnd {
15934                                next_cursor,
15935                                bootstrap_state,
15936                            } => {
15937                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
15938                                break;
15939                            }
15940                            Frame::Unknown { .. } => {}
15941                            other => body.push(other),
15942                        }
15943                    }
15944                    if let Err(SectionError::Abort(code, message)) = self.process_section(
15945                        transport,
15946                        &id,
15947                        status,
15948                        &reason_code,
15949                        effective_scopes,
15950                        body,
15951                        sub_end,
15952                        meta,
15953                        &mut report,
15954                    ) {
15955                        failure = Some((code, message));
15956                        break;
15957                    }
15958                }
15959                Frame::Lease {
15960                    lease_id,
15961                    expires_at_ms,
15962                } => {
15963                    // §7.3.5: persist the opaque lease; a fresh lease clears
15964                    // any prior lease error (the outage/revocation is over).
15965                    self.set_lease_state(Some(LeaseState {
15966                        lease_id: Some(lease_id),
15967                        expires_at_ms: Some(expires_at_ms),
15968                        error_code: None,
15969                    }));
15970                }
15971                Frame::Error { code, message, .. } => {
15972                    // §1.6: the whole request failed; already-completed
15973                    // subscriptions keep their applied data and cursors.
15974                    failure = Some((code, message));
15975                    break;
15976                }
15977                Frame::Unknown { .. } => {}
15978                _ => {
15979                    failure = Some((
15980                        "sync.invalid_request".to_owned(),
15981                        "unexpected frame in response".to_owned(),
15982                    ));
15983                    break;
15984                }
15985            }
15986        }
15987
15988        if let Some((error_code, message)) = failure {
15989            return SyncOutcome::Failed {
15990                details: None,
15991                error_code,
15992                message,
15993            };
15994        }
15995        // Local apply boundaries already reconciled their affected overlay.
15996        // Refuse round success if any remaining dirty work fails to reconcile.
15997        if let Err(message) = self.rebuild_overlay_if_dirty() {
15998            return SyncOutcome::Failed {
15999                details: None,
16000                error_code: "storage.failed".into(),
16001                message,
16002            };
16003        }
16004        // §4.8 E1: the push half may have drained commits that pinned rows of
16005        // a shrunk window unit — retry any deferred evictions now.
16006        if let Err(message) = self.drain_pending_evictions() {
16007            return SyncOutcome::Failed {
16008                details: None,
16009                error_code: "storage.failed".into(),
16010                message,
16011            };
16012        }
16013        self.ack_after_pull(transport);
16014        // §7.4.5: the reset is over once the first post-reset pull round
16015        // leaves no subscription mid-bootstrap — the tables are rebuilt.
16016        if self.upgrading && report.bootstrapping.is_empty() {
16017            self.set_upgrading(false);
16018        }
16019        SyncOutcome::Ok(report)
16020    }
16021
16022    // -- push results (§6.3, §7.2) ------------------------------------------------
16023
16024    fn handle_push_results(
16025        &mut self,
16026        frames: &[Frame],
16027        pushed_ids: &HashSet<&str>,
16028        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
16029        report: &mut SyncReport,
16030        last_final_push_result_id: Option<&str>,
16031    ) -> Result<(), String> {
16032        self.begin_observation("syncular_push_results")?;
16033        let previous_failed = self.failed_commits.clone();
16034        let previous_sync_needed = self.sync_needed;
16035        let conflict_count = self.conflicts.len();
16036        let rejection_count = self.rejections.len();
16037        let applied_count = report.applied.len();
16038        let rejected_count = report.rejected.len();
16039        let retryable_count = report.retryable.len();
16040        let was_dirty = self.overlay_dirty.snapshot();
16041        let mut removed = Vec::new();
16042        let mut batch = ChangeAccumulator::default();
16043        let persisted = (|| {
16044            for frame in frames {
16045                let Frame::PushResult {
16046                    client_commit_id,
16047                    status,
16048                    results,
16049                    commit_seq,
16050                } = frame
16051                else {
16052                    return Err("unexpected acknowledgement frame".to_owned());
16053                };
16054                if !pushed_ids.contains(client_commit_id.as_str()) {
16055                    continue;
16056                }
16057                let Some(index) = self
16058                    .outbox
16059                    .iter()
16060                    .position(|commit| commit.client_commit_id == *client_commit_id)
16061                else {
16062                    continue;
16063                };
16064                let status = *status;
16065                let rejection_details = rejection_details_by_commit.get(client_commit_id);
16066                let operations = self.outbox[index].ops.clone();
16067                match status {
16068                    PushStatus::Applied | PushStatus::Cached => {
16069                        // §7.2: a lost ack replays as `cached` — proceed as if the
16070                        // ack had arrived.
16071                        let sequence =
16072                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
16073                        let affected: HashSet<_> = operations
16074                            .iter()
16075                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
16076                            .collect();
16077                        let prior = self.acknowledged_operations(Some(&affected), None)?;
16078                        for (op_index, operation) in operations.iter().enumerate() {
16079                            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))
16080                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16081                            if delivered {
16082                                continue;
16083                            }
16084                            let table = self
16085                                .schema
16086                                .table(&operation.table)
16087                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
16088                            let mut intended: Map<String, Value> = self
16089                                .stored_row(table, &operation.row_id, true)?
16090                                .map(|(row, _)| {
16091                                    table
16092                                        .columns
16093                                        .iter()
16094                                        .zip(&row)
16095                                        .map(|(column, value)| {
16096                                            (column.name.clone(), column_value_to_json(value))
16097                                        })
16098                                        .collect()
16099                                })
16100                                .unwrap_or_default();
16101                            let mut scope_image = intended.clone();
16102                            for op in prior
16103                                .iter()
16104                                .chain(
16105                                    self.failed_commits
16106                                        .iter()
16107                                        .flat_map(|(commit, _)| &commit.ops),
16108                                )
16109                                .chain(
16110                                    self.outbox
16111                                        .iter()
16112                                        .take(index)
16113                                        .flat_map(|commit| &commit.ops),
16114                                )
16115                                .chain(operations.iter().take(op_index + 1))
16116                            {
16117                                if op.table != operation.table || op.row_id != operation.row_id {
16118                                    continue;
16119                                }
16120                                if let Some(values) = &op.values {
16121                                    intended.extend(values.clone());
16122                                } else {
16123                                    scope_image = intended.clone();
16124                                    intended.clear();
16125                                }
16126                            }
16127                            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)",
16128                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
16129                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
16130                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
16131                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16132                        }
16133                        let journal_results = results
16134                            .iter()
16135                            .map(|result| {
16136                                let op_index = match result {
16137                                    OpResult::Applied { op_index }
16138                                    | OpResult::Conflict { op_index, .. }
16139                                    | OpResult::Error { op_index, .. } => *op_index,
16140                                };
16141                                CommitOperationOutcome::Applied { op_index }
16142                            })
16143                            .collect::<Vec<_>>();
16144                        let outcome_status = if status == PushStatus::Applied {
16145                            CommitOutcomeStatus::Applied
16146                        } else {
16147                            CommitOutcomeStatus::Cached
16148                        };
16149                        let persisted = self
16150                            .persist_commit_outcome(
16151                                client_commit_id,
16152                                outcome_status,
16153                                &journal_results,
16154                                None,
16155                            )
16156                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16157                        persisted?;
16158                        for operation in &operations {
16159                            self.overlay_dirty.table(&operation.table);
16160                        }
16161                        self.sync_needed = true;
16162                        batch.status = true;
16163                        batch.outcomes = true;
16164                    }
16165                    PushStatus::Rejected => {
16166                        if results.iter().any(|result| {
16167                            matches!(
16168                                result,
16169                                OpResult::Error {
16170                                    code,
16171                                    retryable: true,
16172                                    ..
16173                                } if code == "sync.idempotency_cache_miss"
16174                            )
16175                        }) {
16176                            // §6.3/§7.2: a serving failure, not an outcome — keep the
16177                            // exact commit queued for an identical retry.
16178                            report.retryable.push(client_commit_id.to_owned());
16179                            continue;
16180                        }
16181
16182                        let mut journal_results = Vec::with_capacity(results.len());
16183                        let mut conflicts = Vec::new();
16184                        let mut rejections = Vec::new();
16185                        for result in results {
16186                            match result {
16187                                OpResult::Applied { op_index } => {
16188                                    journal_results.push(CommitOperationOutcome::Applied {
16189                                        op_index: *op_index,
16190                                    });
16191                                }
16192                                OpResult::Conflict {
16193                                    op_index,
16194                                    code,
16195                                    message,
16196                                    server_version,
16197                                    server_row,
16198                                    conflict_columns: conflict_columns_bitmap,
16199                                } => {
16200                                    let operation = operations
16201                                        .get(*op_index as usize)
16202                                        .map(CommitOperation::from);
16203                                    let (table, row_id) = operation
16204                                        .as_ref()
16205                                        .map(|op| (op.table.clone(), op.row_id.clone()))
16206                                        .unwrap_or_default();
16207                                    let server_row_json = self
16208                                        .schema
16209                                        .table(&table)
16210                                        .and_then(|t| {
16211                                            decode_row_bytes(t, server_row, &self.encryption)
16212                                                .ok()
16213                                                .map(|row| (t, row))
16214                                        })
16215                                        .map(|(t, row)| {
16216                                            let mut map = Map::new();
16217                                            for (i, column) in t.columns.iter().enumerate() {
16218                                                map.insert(
16219                                                    column.name.clone(),
16220                                                    column_value_to_json(
16221                                                        row.get(i).unwrap_or(&None),
16222                                                    ),
16223                                                );
16224                                            }
16225                                            map
16226                                        })
16227                                        .unwrap_or_default();
16228                                    // §6.3: decode the presence-layout bitmap
16229                                    // into the marked column names.
16230                                    let conflict_columns = self
16231                                        .schema
16232                                        .table(&table)
16233                                        .map(|t| {
16234                                            t.columns
16235                                                .iter()
16236                                                .enumerate()
16237                                                .filter(|(index, _)| {
16238                                                    conflict_columns_bitmap
16239                                                        .get(index / 8)
16240                                                        .is_some_and(|byte| {
16241                                                            byte & (1 << (index % 8)) != 0
16242                                                        })
16243                                                })
16244                                                .map(|(_, column)| column.name.clone())
16245                                                .collect::<Vec<_>>()
16246                                        })
16247                                        .unwrap_or_default();
16248                                    let conflict = ConflictRecord {
16249                                        client_commit_id: client_commit_id.to_owned(),
16250                                        op_index: *op_index,
16251                                        table,
16252                                        row_id,
16253                                        code: code.clone(),
16254                                        message: message.clone(),
16255                                        server_version: *server_version,
16256                                        server_row: server_row_json,
16257                                        conflict_columns,
16258                                        operation,
16259                                    };
16260                                    journal_results.push(CommitOperationOutcome::Conflict {
16261                                        conflict: conflict.clone(),
16262                                    });
16263                                    conflicts.push(conflict);
16264                                }
16265                                OpResult::Error {
16266                                    op_index,
16267                                    code,
16268                                    message,
16269                                    retryable,
16270                                } => {
16271                                    let rejection = RejectionRecord {
16272                                        client_commit_id: client_commit_id.to_owned(),
16273                                        op_index: *op_index,
16274                                        code: code.clone(),
16275                                        message: message.clone(),
16276                                        retryable: *retryable,
16277                                        details: rejection_details
16278                                            .and_then(|details| details.get(op_index))
16279                                            .cloned(),
16280                                        operation: operations
16281                                            .get(*op_index as usize)
16282                                            .map(CommitOperation::from),
16283                                    };
16284                                    journal_results.push(CommitOperationOutcome::Error {
16285                                        rejection: rejection.clone(),
16286                                    });
16287                                    rejections.push(rejection);
16288                                }
16289                            }
16290                        }
16291                        if self.retain_failed_commits {
16292                            self.retain_failed_commit(client_commit_id, &operations)?;
16293                        }
16294                        let outcome_status = if conflicts.is_empty() {
16295                            CommitOutcomeStatus::Rejected
16296                        } else {
16297                            CommitOutcomeStatus::Conflict
16298                        };
16299                        let persisted = self
16300                            .persist_commit_outcome(
16301                                client_commit_id,
16302                                outcome_status,
16303                                &journal_results,
16304                                Some(&operations),
16305                            )
16306                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16307                        persisted?;
16308                        batch.conflicts = !conflicts.is_empty();
16309                        batch.rejections = !rejections.is_empty();
16310                        batch.status = true;
16311                        batch.outcomes = true;
16312                        self.conflicts.extend(conflicts);
16313                        self.rejections.extend(rejections);
16314                        for operation in &operations {
16315                            self.overlay_dirty.table(&operation.table);
16316                        }
16317                    }
16318                }
16319                removed.push((index, self.outbox.remove(index)));
16320                if batch.status {
16321                    for operation in &operations {
16322                        if !self.record_row_scopes(
16323                            &mut batch,
16324                            &operation.table,
16325                            &operation.row_id,
16326                            false,
16327                        ) {
16328                            batch.table(&operation.table);
16329                        }
16330                    }
16331                }
16332                match status {
16333                    PushStatus::Applied | PushStatus::Cached => {
16334                        report.applied.push(client_commit_id.to_owned())
16335                    }
16336                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
16337                }
16338            }
16339            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)) {
16340                self.prune_commit_outcomes()?;
16341            }
16342            self.rebuild_overlay_if_dirty()?;
16343            self.finish_observation("syncular_push_results", batch)
16344        })();
16345        if let Err(error) = persisted {
16346            self.rollback_observation("syncular_push_results");
16347            self.failed_commits = previous_failed;
16348            self.sync_needed = previous_sync_needed;
16349            for (index, commit) in removed.into_iter().rev() {
16350                self.outbox.insert(index, commit);
16351            }
16352            self.conflicts.truncate(conflict_count);
16353            self.rejections.truncate(rejection_count);
16354            self.overlay_dirty.restore(was_dirty.clone());
16355            report.applied.truncate(applied_count);
16356            report.rejected.truncate(rejected_count);
16357            report.retryable.truncate(retryable_count);
16358            return Err(error);
16359        }
16360        if !previous_sync_needed && self.sync_needed {
16361            self.sync_intent_queue.push_back(SyncIntent::Interactive);
16362        }
16363        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
16364        Ok(())
16365    }
16366
16367    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
16368        status != PushStatus::Rejected
16369            || !results.iter().any(|result| {
16370                matches!(
16371                    result,
16372                    OpResult::Error {
16373                        code,
16374                        retryable: true,
16375                        ..
16376                    } if code == "sync.idempotency_cache_miss"
16377                )
16378            })
16379    }
16380
16381    // -- subscription sections ------------------------------------------------------
16382
16383    #[allow(clippy::too_many_arguments)]
16384    fn process_section(
16385        &mut self,
16386        transport: &mut dyn Transport,
16387        id: &str,
16388        status: SubStatus,
16389        reason_code: &str,
16390        effective_scopes: Vec<(String, Vec<String>)>,
16391        body: Vec<Frame>,
16392        sub_end: Option<(i64, Option<String>)>,
16393        meta: &RequestMeta,
16394        report: &mut SyncReport,
16395    ) -> Result<(), SectionError> {
16396        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
16397            return Ok(()); // unknown echo: ignore
16398        };
16399        if status != SubStatus::Active && sub_end.is_none() {
16400            return Err(SectionError::Abort(
16401                "sync.invalid_request".into(),
16402                "subscription section without SUB_END".into(),
16403            ));
16404        }
16405        match status {
16406            SubStatus::Revoked => {
16407                self.begin_observation("syncular_revocation")
16408                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
16409                let previous = self.subs[sub_index].clone();
16410                let was_dirty = self.overlay_dirty.snapshot();
16411                let previous_outbox = self.outbox.clone();
16412                let previous_failed = self.failed_commits.clone();
16413                let previous_rejections = self.rejections.clone();
16414                let previous_conflicts = self.conflicts.clone();
16415                let revoked_count = report.revoked.len();
16416                let failed_count = report.failed.len();
16417                let result = (|| {
16418                    let mut batch = ChangeAccumulator::default();
16419                    let registered = self.window_unit_by_sub_id(id);
16420                    // §3.3: stop pulling, purge exactly the last effective grant.
16421                    let (table, effective) = {
16422                        let sub = &self.subs[sub_index];
16423                        (sub.table.clone(), sub.effective.clone().unwrap_or_default())
16424                    };
16425                    let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
16426                        SectionError::Abort(
16427                            "sync.unknown_table".to_owned(),
16428                            "revoked scope references an unknown table".to_owned(),
16429                        )
16430                    })?;
16431                    let dropped = self
16432                        .drop_failed_commits(|name, values| {
16433                            name == table
16434                                && !effective.is_empty()
16435                                && effective.iter().all(|(variable, allowed)| {
16436                                    scoped_table
16437                                        .scope_column(variable)
16438                                        .and_then(|column| values.get(column))
16439                                        .and_then(Value::as_str)
16440                                        .is_some_and(|value| {
16441                                            allowed.iter().any(|allowed| allowed == value)
16442                                        })
16443                                })
16444                        })
16445                        .map_err(|message| {
16446                            SectionError::Abort("storage.failed".to_owned(), message)
16447                        })?;
16448                    for commit in dropped {
16449                        batch.conflicts = true;
16450                        batch.rejections = true;
16451                        batch.outcomes = true;
16452                        for operation in commit.ops {
16453                            batch.table(&operation.table);
16454                        }
16455                    }
16456                    let purged = self.purge_scope_rows(&table, &effective);
16457                    match purged {
16458                        Ok(()) => {
16459                            self.record_scope_map(&mut batch, &table, &effective);
16460                            let sub = &mut self.subs[sub_index];
16461                            sub.state = SubState::Revoked;
16462                            sub.reason_code = Some(if reason_code.is_empty() {
16463                                "sync.scope_revoked".to_owned()
16464                            } else {
16465                                reason_code.to_owned()
16466                            });
16467                            report.revoked.push(id.to_owned());
16468                            let doomed_effective = effective;
16469                            let sub_table = table;
16470                            self.persist_sub(&self.subs[sub_index].clone())
16471                                .map_err(|message| {
16472                                    SectionError::Abort("storage.failed".into(), message)
16473                                })?;
16474                            let dropped = self
16475                                .drop_doomed_outbox(&sub_table, &doomed_effective)
16476                                .map_err(|message| {
16477                                    SectionError::Abort("storage.failed".to_owned(), message)
16478                                })?;
16479                            if dropped {
16480                                batch.status = true;
16481                                batch.rejections = true;
16482                                batch.outcomes = true;
16483                            }
16484                            // §5.9.7 B2: revocation deletes now-unauthorized blob
16485                            // bodies (evicted ≠ revoked).
16486                            self.delete_unreferenced_cached_blobs().map_err(|message| {
16487                                SectionError::Abort("storage.failed".into(), message)
16488                            })?;
16489                        }
16490                        Err(()) => {
16491                            // §3.3 fail closed: no local mapping — never clear by
16492                            // approximation; fatal configuration error.
16493                            let sub = &mut self.subs[sub_index];
16494                            sub.state = SubState::Failed;
16495                            sub.reason_code = Some("sync.scope_revoked".to_owned());
16496                            report.failed.push(id.to_owned());
16497                            self.persist_sub(&self.subs[sub_index].clone())
16498                                .map_err(|message| {
16499                                    SectionError::Abort("storage.failed".into(), message)
16500                                })?;
16501                        }
16502                    }
16503                    if let Some((base_key, unit)) = registered {
16504                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
16505                    }
16506                    self.rebuild_overlay_if_dirty()
16507                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
16508                    self.finish_observation("syncular_revocation", batch)
16509                        .map_err(|message| {
16510                            SectionError::Abort("storage.failed".to_owned(), message)
16511                        })?;
16512                    Ok(())
16513                })();
16514                if result.is_err() {
16515                    self.rollback_observation("syncular_revocation");
16516                    self.subs[sub_index] = previous;
16517                    self.overlay_dirty.restore(was_dirty.clone());
16518                    self.outbox = previous_outbox;
16519                    self.failed_commits = previous_failed;
16520                    self.rejections = previous_rejections;
16521                    self.conflicts = previous_conflicts;
16522                    report.revoked.truncate(revoked_count);
16523                    report.failed.truncate(failed_count);
16524                }
16525                result
16526            }
16527            SubStatus::Reset => {
16528                self.begin_observation("syncular_reset")
16529                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
16530                let previous = self.subs[sub_index].clone();
16531                let was_dirty = self.overlay_dirty.snapshot();
16532                let reset_count = report.resets.len();
16533                let result = (|| {
16534                    let mut batch = ChangeAccumulator::default();
16535                    let registered = self.window_unit_by_sub_id(id);
16536                    // §4.6: discard cursor + bootstrap state, keep local rows —
16537                    // reset is a staleness signal, not a purge signal.
16538                    let sub = &mut self.subs[sub_index];
16539                    sub.cursor = -1;
16540                    sub.bootstrap_state = None;
16541                    sub.reason_code = Some(if reason_code.is_empty() {
16542                        "sync.cursor_expired".to_owned()
16543                    } else {
16544                        reason_code.to_owned()
16545                    });
16546                    report.resets.push(id.to_owned());
16547                    self.persist_sub(&self.subs[sub_index].clone())
16548                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
16549                    if let Some((base_key, unit)) = registered {
16550                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
16551                    }
16552                    self.finish_observation("syncular_reset", batch)
16553                        .map_err(|message| {
16554                            SectionError::Abort("storage.failed".to_owned(), message)
16555                        })?;
16556                    Ok(())
16557                })();
16558                if result.is_err() {
16559                    self.rollback_observation("syncular_reset");
16560                    self.subs[sub_index] = previous;
16561                    self.overlay_dirty.restore(was_dirty.clone());
16562                    report.resets.truncate(reset_count);
16563                }
16564                result
16565            }
16566            SubStatus::Active => {
16567                let fresh = meta
16568                    .fresh
16569                    .iter()
16570                    .find(|(fid, _)| fid == id)
16571                    .map(|(_, fresh)| *fresh)
16572                    .unwrap_or(false);
16573                let previous = self.subs[sub_index].clone();
16574                self.subs[sub_index].effective = Some(effective_scopes);
16575                let outcome = self
16576                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
16577                    .and_then(|()| {
16578                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
16579                            SectionError::Abort(
16580                                "sync.invalid_request".into(),
16581                                "subscription section without SUB_END".into(),
16582                            )
16583                        })?;
16584                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
16585                            .map_err(|message| {
16586                                SectionError::Abort("storage.failed".into(), message)
16587                            })
16588                    });
16589                match outcome {
16590                    Ok(()) => {
16591                        if self.subs[sub_index].bootstrap_state.is_some() {
16592                            report.bootstrapping.push(id.to_owned());
16593                        }
16594                        Ok(())
16595                    }
16596                    Err(SectionError::FailClosed) => {
16597                        self.subs[sub_index] = previous.clone();
16598                        self.begin_observation("syncular_section_failure")
16599                            .map_err(|message| {
16600                                SectionError::Abort("storage.failed".into(), message)
16601                            })?;
16602                        let persisted = (|| {
16603                            let mut batch = ChangeAccumulator::default();
16604                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
16605                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
16606                            }
16607                            self.subs[sub_index].state = SubState::Failed;
16608                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
16609                            self.persist_sub(&self.subs[sub_index])?;
16610                            self.finish_observation("syncular_section_failure", batch)
16611                        })();
16612                        if let Err(message) = persisted {
16613                            self.rollback_observation("syncular_section_failure");
16614                            self.subs[sub_index] = previous;
16615                            return Err(SectionError::Abort("storage.failed".into(), message));
16616                        }
16617                        report.failed.push(id.to_owned());
16618                        Ok(())
16619                    }
16620                    Err(error) => {
16621                        // Frame/block transactions already completed. Preserve their
16622                        // rows and revisions, but leave this subscription cursor unchanged.
16623                        self.subs[sub_index] = previous;
16624                        Err(error)
16625                    }
16626                }
16627            }
16628        }
16629    }
16630
16631    fn apply_sub_end(
16632        &mut self,
16633        sub_index: usize,
16634        cursor: i64,
16635        bootstrap_state: Option<String>,
16636    ) -> Result<(), String> {
16637        #[cfg(feature = "bench-internals")]
16638        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
16639        let previous = self.subs[sub_index].clone();
16640        self.begin_observation("syncular_sub_end")?;
16641        let persisted = (|| {
16642            let mut batch = ChangeAccumulator::default();
16643            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
16644                && cursor >= 0
16645                && bootstrap_state.is_none();
16646            if completed {
16647                let table = self
16648                    .schema
16649                    .table(&previous.table)
16650                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
16651                let effective = previous.effective.as_deref().unwrap_or(&[]);
16652                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
16653                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16654                let rows = statement
16655                    .query_map(rusqlite::params![previous.table, cursor], |row| {
16656                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
16657                    })
16658                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16659                let mut covered = HashSet::new();
16660                for row in rows {
16661                    let (id, intent) =
16662                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16663                    let values: Map<String, Value> = serde_json::from_str(&intent)
16664                        .map_err(|_| "sync.local_corrupt".to_owned())?;
16665                    if !effective.is_empty()
16666                        && effective.iter().all(|(variable, allowed)| {
16667                            table
16668                                .scope_column(variable)
16669                                .and_then(|column| values.get(column))
16670                                .is_some_and(|value| {
16671                                    allowed.contains(
16672                                        &value
16673                                            .as_str()
16674                                            .map_or_else(|| value.to_string(), str::to_owned),
16675                                    )
16676                                })
16677                        })
16678                    {
16679                        covered.insert(id);
16680                    }
16681                }
16682                drop(statement);
16683                for id in covered {
16684                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
16685                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16686                    self.overlay_dirty.table(&previous.table);
16687                    batch.table(&previous.table);
16688                }
16689                self.rebuild_overlay_if_dirty()?;
16690                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
16691                    batch.window(&base_key, &previous.table, &unit);
16692                }
16693            }
16694            let sub = &mut self.subs[sub_index];
16695            sub.cursor = cursor;
16696            sub.bootstrap_state = bootstrap_state;
16697            // §4.6: a completed (or continuing) active end clears the reset /
16698            // revocation reason, matching the TS core's active-state persist.
16699            sub.reason_code = None;
16700            sub.synced_once = true;
16701            self.persist_sub(&self.subs[sub_index])?;
16702            self.finish_observation("syncular_sub_end", batch)
16703        })();
16704        if persisted.is_err() {
16705            self.rollback_observation("syncular_sub_end");
16706            self.subs[sub_index] = previous;
16707        }
16708        persisted
16709    }
16710
16711    fn apply_section_body(
16712        &mut self,
16713        transport: &mut dyn Transport,
16714        sub_index: usize,
16715        body: Vec<Frame>,
16716        fresh: bool,
16717        meta: &RequestMeta,
16718        report: &mut SyncReport,
16719    ) -> Result<(), SectionError> {
16720        let mut saw_segment = false;
16721        for frame in body {
16722            match frame {
16723                Frame::Commit {
16724                    tables,
16725                    changes,
16726                    commit_seq,
16727                    ..
16728                } => {
16729                    self.apply_commit_frame(&tables, &changes, Some(commit_seq))
16730                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
16731                    report.commits_applied += 1;
16732                }
16733                Frame::SegmentInline { payload } => {
16734                    let segment = decode_rows_segment(&payload)
16735                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
16736                    self.progress.update(|p| {
16737                        p.phase = ProgressPhase::Import;
16738                        p.subscription_id = Some(self.subs[sub_index].id.clone());
16739                        p.table = Some(self.subs[sub_index].table.clone());
16740                        p.segment_id = None;
16741                        p.bytes_received = payload.len() as u64;
16742                        p.bytes_total = Some(payload.len() as u64);
16743                        p.rows_processed = 0;
16744                        p.rows_total =
16745                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
16746                    });
16747                    let first = !saw_segment;
16748                    saw_segment = true;
16749                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
16750                    report.segment_rows_applied += applied;
16751                }
16752                Frame::SegmentRef {
16753                    segment_id,
16754                    byte_length,
16755                    media_type,
16756                    table,
16757                    row_count,
16758                    as_of_commit_seq,
16759                    scope_digest,
16760                    row_cursor,
16761                    next_row_cursor,
16762                    url,
16763                    url_expires_at_ms,
16764                    ..
16765                } => {
16766                    // §4.2: reject a descriptor whose mediaType was not
16767                    // advertised — never skip or guess.
16768                    let advertised = match media_type {
16769                        MediaType::Rows => {
16770                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
16771                                || meta.accept & ACCEPT_INLINE_ROWS != 0
16772                        }
16773                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
16774                    };
16775                    if !advertised {
16776                        return Err(SectionError::Abort(
16777                            "sync.invalid_request".to_owned(),
16778                            format!(
16779                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
16780                                media_type.name()
16781                            ),
16782                        ));
16783                    }
16784                    self.progress.update(|p| {
16785                        p.phase = ProgressPhase::Download;
16786                        p.subscription_id = Some(self.subs[sub_index].id.clone());
16787                        p.table = Some(table.clone());
16788                        p.segment_id = Some(segment_id.clone());
16789                        p.bytes_received = 0;
16790                        p.bytes_total = u64::try_from(byte_length).ok();
16791                        p.rows_processed = 0;
16792                        p.rows_total = u64::try_from(row_count).ok();
16793                    });
16794                    let observer = self.progress.clone();
16795                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
16796                    let bytes = if let Some(url) = url {
16797                        // §5.4: a url-carrying descriptor MUST be fetched
16798                        // from that URL; failure invalidates the whole
16799                        // descriptor (no fall-through to §5.5 — re-pull
16800                        // recovers, §1.4 rule 5).
16801                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
16802                            return Err(SectionError::Abort(
16803                                "sync.invalid_request".to_owned(),
16804                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
16805                                    .to_owned(),
16806                            ));
16807                        }
16808                        // §5.4: MUST NOT start a fetch at/past expiry.
16809                        if url_expires_at_ms.is_some_and(|exp| {
16810                            exp <= transport
16811                                .segment_fetch_started_at(&url)
16812                                .unwrap_or_else(|| self.clock_now_ms())
16813                        }) {
16814                            return Err(SectionError::Abort(
16815                                "sync.segment_expired".to_owned(),
16816                                format!(
16817                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
16818                                ),
16819                            ));
16820                        }
16821                        transport
16822                            .fetch_url(&url, &mut on_progress)
16823                            .map_err(|e| SectionError::Abort(e.code, e.message))?
16824                    } else {
16825                        let requested_scopes_json =
16826                            canonical_scope_json(&self.subs[sub_index].requested);
16827                        transport
16828                            .download_segment(
16829                                &SegmentRequest {
16830                                    segment_id: segment_id.clone(),
16831                                    table,
16832                                    requested_scopes_json,
16833                                },
16834                                &mut on_progress,
16835                            )
16836                            .map_err(|e| SectionError::Abort(e.code, e.message))?
16837                    };
16838                    // §5.1: verify the content address before applying.
16839                    on_progress(bytes.len() as u64);
16840                    let digest = Sha256::digest(&bytes);
16841                    let expected = segment_id
16842                        .strip_prefix("sha256:")
16843                        .unwrap_or(segment_id.as_str());
16844                    if bytes_to_hex(&digest) != expected {
16845                        // §5.1: the client discards the segment and re-pulls.
16846                        self.schedule_background_retry();
16847                        return Err(SectionError::Abort(
16848                            "sync.invalid_request".to_owned(),
16849                            "segment bytes do not match the content address (§5.1)".to_owned(),
16850                        ));
16851                    }
16852                    self.progress.update(|p| p.phase = ProgressPhase::Import);
16853                    if media_type == MediaType::Sqlite {
16854                        // §5.3: images are whole-table — a paged sqlite
16855                        // descriptor is invalid.
16856                        if row_cursor.is_some() || next_row_cursor.is_some() {
16857                            return Err(SectionError::Abort(
16858                                "sync.invalid_request".to_owned(),
16859                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
16860                                    .to_owned(),
16861                            ));
16862                        }
16863                        let first = !saw_segment;
16864                        saw_segment = true;
16865                        let applied = self.apply_sqlite_segment(
16866                            sub_index,
16867                            &bytes,
16868                            fresh && first,
16869                            row_count,
16870                            as_of_commit_seq,
16871                            &scope_digest,
16872                        )?;
16873                        report.segment_rows_applied += applied;
16874                    } else {
16875                        let segment = decode_rows_segment(&bytes).map_err(|e| {
16876                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
16877                        })?;
16878                        let first = row_cursor.is_none();
16879                        saw_segment = true;
16880                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
16881                        report.segment_rows_applied += applied;
16882                    }
16883                }
16884                Frame::Unknown { .. } => {}
16885                Frame::Error { code, message, .. } => {
16886                    return Err(SectionError::Abort(code, message))
16887                }
16888                _ => {
16889                    return Err(SectionError::Abort(
16890                        "sync.invalid_request".to_owned(),
16891                        "unexpected frame inside a subscription section".to_owned(),
16892                    ));
16893                }
16894            }
16895        }
16896        Ok(())
16897    }
16898
16899    // Both pull and realtime frames use the same durable observation boundary.
16900    fn apply_commit_frame(
16901        &mut self,
16902        tables: &[String],
16903        changes: &[ssp2::model::Change],
16904        commit_seq: Option<i64>,
16905    ) -> Result<(), (String, String)> {
16906        #[cfg(feature = "bench-internals")]
16907        let _phase = self.benchmark_phases.start(Phase::CommitApply);
16908        let was_dirty = self.overlay_dirty.snapshot();
16909        self.begin_observation("syncular_commit")
16910            .map_err(|message| ("storage.failed".into(), message))?;
16911        let applied = (|| {
16912            let mut batch = ChangeAccumulator::default();
16913            self.record_commit_changes(&mut batch, tables, changes);
16914            if let Some(sequence) = commit_seq {
16915                for change in changes {
16916                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
16917                        (
16918                            "sync.invalid_request".to_owned(),
16919                            "change tableIndex out of range".to_owned(),
16920                        )
16921                    })?;
16922                    if self
16923                        .outbox
16924                        .iter()
16925                        .flat_map(|commit| &commit.ops)
16926                        .any(|op| op.table == *table && op.row_id == change.row_id)
16927                    {
16928                        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])
16929                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
16930                    }
16931                    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])
16932                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
16933                }
16934            }
16935            self.apply_commit_changes(tables, changes)?;
16936            self.rebuild_overlay_if_dirty()
16937                .map_err(|message| ("storage.failed".into(), message))?;
16938            self.finish_observation("syncular_commit", batch)
16939                .map_err(|message| ("storage.failed".into(), message))
16940        })();
16941        if applied.is_err() {
16942            self.rollback_observation("syncular_commit");
16943            self.overlay_dirty.restore(was_dirty.clone());
16944        }
16945        applied
16946    }
16947
16948    fn apply_commit_changes(
16949        &mut self,
16950        tables: &[String],
16951        changes: &[ssp2::model::Change],
16952    ) -> Result<(), (String, String)> {
16953        // A clean overlay can reconcile changed primary keys independently
16954        // when their tables have no secondary unique constraints. Such a
16955        // constraint can change whether another row's pending write succeeds,
16956        // so those frames still require the complete FIFO replay below.
16957        let mirror_visible = !self.overlay_dirty.get()
16958            && self.failed_commits.is_empty()
16959            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
16960                || changes.iter().all(|change| {
16961                    tables
16962                        .get(change.table_index as usize)
16963                        .and_then(|name| self.schema.table(name))
16964                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
16965                }));
16966        for change in changes {
16967            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
16968                (
16969                    "sync.invalid_request".to_owned(),
16970                    "change tableIndex out of range".to_owned(),
16971                )
16972            })?;
16973            let table = self.schema.table(table_name).ok_or_else(|| {
16974                (
16975                    "sync.schema_mismatch".to_owned(),
16976                    format!("change targets unknown table {table_name:?}"),
16977                )
16978            })?;
16979            match change.op {
16980                Op::Upsert => {
16981                    let payload = change.row.as_ref().ok_or_else(|| {
16982                        (
16983                            "sync.invalid_request".to_owned(),
16984                            "upsert change without row payload".to_owned(),
16985                        )
16986                    })?;
16987                    // §5.11: decrypt encrypted columns on apply.
16988                    let row = {
16989                        #[cfg(feature = "bench-internals")]
16990                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
16991                        decode_row_bytes(table, payload, &self.encryption)
16992                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
16993                    };
16994                    let version = change.row_version.unwrap_or(0);
16995                    let table_name = table.name.clone();
16996                    self.write_base_row(&table_name, &row, version)
16997                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
16998                    if mirror_visible {
16999                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
17000                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17001                    }
17002                }
17003                Op::Delete => {
17004                    self.delete_base_row(table_name, &change.row_id)
17005                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17006                    if mirror_visible {
17007                        let sql = format!(
17008                            "DELETE FROM {} WHERE {}",
17009                            visible_table(table_name),
17010                            row_id_predicate(table)
17011                        );
17012                        self.conn
17013                            .prepare_cached(&sql)
17014                            .and_then(|mut statement| {
17015                                statement.execute(rusqlite::params![change.row_id])
17016                            })
17017                            .map_err(|error| {
17018                                ("sync.invalid_request".to_owned(), error.to_string())
17019                            })?;
17020                    }
17021                }
17022            }
17023        }
17024        if mirror_visible {
17025            {
17026                let changed_rows: HashSet<(&str, &str)> = changes
17027                    .iter()
17028                    .map(|change| {
17029                        (
17030                            tables[change.table_index as usize].as_str(),
17031                            change.row_id.as_str(),
17032                        )
17033                    })
17034                    .collect();
17035                let acknowledged = self
17036                    .acknowledged_operations(Some(&changed_rows), None)
17037                    .map_err(|error| ("storage.failed".to_owned(), error))?;
17038                self.apply_outbox_ops(&acknowledged)
17039                    .map_err(|message| ("storage.failed".into(), message))?;
17040                self.apply_outbox_ops(
17041                    self.outbox
17042                        .iter()
17043                        .flat_map(|commit| &commit.ops)
17044                        .filter(|op| {
17045                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
17046                        }),
17047                )
17048                .map_err(|message| ("storage.failed".into(), message))?;
17049            }
17050            self.overlay_dirty.set(false);
17051        }
17052        Ok(())
17053    }
17054
17055    /// §5.6 segment application: validate against the generated schema,
17056    /// clear the grant on a fresh bootstrap's first page (fail closed
17057    /// without a mapping), then replace-or-upsert each row with its
17058    /// segment-carried server version (§5.2).
17059    fn apply_segment(
17060        &mut self,
17061        sub_index: usize,
17062        segment: &RowsSegment,
17063        first_fresh_page: bool,
17064    ) -> Result<u32, SectionError> {
17065        let (sub_table, effective) = {
17066            let sub = &self.subs[sub_index];
17067            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17068        };
17069        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17070            SectionError::Abort(
17071                "sync.schema_mismatch".to_owned(),
17072                format!("subscription table {sub_table:?} is not in the client schema"),
17073            )
17074        })?;
17075        // §5.2: the column table validates against the generated schema —
17076        // order, names, types, nullability; mismatch is fatal. §5.11: the
17077        // server sends the WIRE types (bytes for an encrypted column), so
17078        // validate against wire_columns.
17079        let matches = segment.table == table.name
17080            && segment.schema_version == self.schema.version
17081            && segment.columns.len() == table.wire_columns.len()
17082            && segment
17083                .columns
17084                .iter()
17085                .zip(table.wire_columns.iter())
17086                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
17087        if !matches {
17088            return Err(SectionError::Abort(
17089                "sync.schema_mismatch".to_owned(),
17090                "segment column table does not match the generated schema (§5.2)".to_owned(),
17091            ));
17092        }
17093        let mut applied = 0u32;
17094        let empty = Vec::new();
17095        let blocks = if segment.blocks.is_empty() {
17096            std::slice::from_ref(&empty)
17097        } else {
17098            &segment.blocks
17099        };
17100        for (index, block) in blocks.iter().enumerate() {
17101            let clear = first_fresh_page && index == 0;
17102            if !clear && block.is_empty() {
17103                continue;
17104            }
17105            let was_dirty = self.overlay_dirty.snapshot();
17106            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17107                && self
17108                    .outbox
17109                    .iter()
17110                    .flat_map(|commit| &commit.ops)
17111                    .any(|op| op.table == table.name);
17112            let mirror_visible = !reconcile_pending
17113                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17114            self.begin_observation("syncular_segment_block")
17115                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17116            let outcome = (|| {
17117                self.rebuild_overlay_if_dirty()
17118                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17119                let mut changed_keys = Vec::new();
17120                let mut batch = ChangeAccumulator::default();
17121                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
17122                    batch.table(&table.name);
17123                }
17124                if clear {
17125                    self.purge_scope_rows(&table.name, &effective)
17126                        .map_err(|()| SectionError::FailClosed)?;
17127                }
17128                for (row_index, row) in block.iter().enumerate() {
17129                    let decrypted;
17130                    let values = if table.has_encrypted_columns() {
17131                        let mut values = row.values.clone();
17132                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
17133                            .map_err(|message| {
17134                            SectionError::Abort("client.decrypt_failed".into(), message)
17135                        })?;
17136                        decrypted = values;
17137                        &decrypted
17138                    } else {
17139                        &row.values
17140                    };
17141                    self.write_base_row(&table.name, values, row.server_version)
17142                        .map_err(|message| {
17143                            SectionError::Abort("sync.invalid_request".into(), message)
17144                        })?;
17145                    if reconcile_pending {
17146                        changed_keys.push(
17147                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
17148                                |message| SectionError::Abort("storage.failed".into(), message),
17149                            )?,
17150                        );
17151                    }
17152                    if mirror_visible {
17153                        self.write_row(
17154                            &visible_table(&table.name),
17155                            &table.name,
17156                            values,
17157                            row.server_version,
17158                        )
17159                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17160                    }
17161                    let processed = u64::from(applied) + row_index as u64 + 1;
17162                    if processed.is_multiple_of(1024) {
17163                        self.progress.update(|p| p.rows_processed = processed);
17164                    }
17165                }
17166                if reconcile_pending {
17167                    self.reconcile_imported_rows(&table, &mut changed_keys)
17168                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17169                }
17170                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17171                    self.apply_outbox_ops(
17172                        self.outbox
17173                            .iter()
17174                            .flat_map(|commit| &commit.ops)
17175                            .filter(|op| op.table == table.name),
17176                    )
17177                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17178                    self.overlay_dirty.set(false);
17179                } else {
17180                    self.rebuild_overlay_if_dirty()
17181                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17182                }
17183                self.finish_observation("syncular_segment_block", batch)
17184                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
17185            })();
17186            if let Err(error) = outcome {
17187                self.rollback_observation("syncular_segment_block");
17188                self.overlay_dirty.restore(was_dirty.clone());
17189                return Err(error);
17190            }
17191            applied += block.len() as u32;
17192            self.progress
17193                .update(|p| p.rows_processed = u64::from(applied));
17194            std::thread::yield_now();
17195        }
17196        self.progress
17197            .update(|p| p.rows_processed = u64::from(applied));
17198        Ok(applied)
17199    }
17200
17201    /// §5.3 sqlite-image application: validate the in-file metadata
17202    /// against the descriptor, validate column names/order against the
17203    /// generated schema, run the §5.6 first-page clear when fresh, then
17204    /// replace-or-upsert every image row with its `_syncular_version`.
17205    /// Mechanics: the image lands in a temp file read through a second
17206    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
17207    /// Each image chunk commits its writes and revision before readers continue.
17208    fn apply_sqlite_segment(
17209        &mut self,
17210        sub_index: usize,
17211        bytes: &[u8],
17212        first_fresh_page: bool,
17213        row_count: i64,
17214        as_of_commit_seq: i64,
17215        scope_digest: &str,
17216    ) -> Result<u32, SectionError> {
17217        let invalid = |detail: &str| {
17218            SectionError::Abort(
17219                "sync.invalid_request".to_owned(),
17220                format!("sqlite segment rejected: {detail} (§5.3)"),
17221            )
17222        };
17223        let (sub_table, effective) = {
17224            let sub = &self.subs[sub_index];
17225            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17226        };
17227        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17228            SectionError::Abort(
17229                "sync.schema_mismatch".to_owned(),
17230                format!("subscription table {sub_table:?} is not in the client schema"),
17231            )
17232        })?;
17233
17234        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
17235        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
17236        let img = match rusqlite::Connection::open_with_flags(
17237            &path,
17238            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
17239        ) {
17240            Ok(conn) => conn,
17241            Err(_) => {
17242                let _ = std::fs::remove_file(&path);
17243                return Err(invalid("bytes do not open as a SQLite database"));
17244            }
17245        };
17246        let outcome = self.apply_sqlite_image(
17247            &img,
17248            &table,
17249            first_fresh_page,
17250            &effective,
17251            row_count,
17252            as_of_commit_seq,
17253            scope_digest,
17254        );
17255        drop(img);
17256        let _ = std::fs::remove_file(&path);
17257        outcome
17258    }
17259
17260    #[allow(clippy::too_many_arguments)]
17261    fn apply_sqlite_image(
17262        &mut self,
17263        img: &rusqlite::Connection,
17264        table: &crate::schema::TableSchema,
17265        first_fresh_page: bool,
17266        effective: &[(String, Vec<String>)],
17267        row_count: i64,
17268        as_of_commit_seq: i64,
17269        scope_digest: &str,
17270    ) -> Result<u32, SectionError> {
17271        let invalid = |detail: String| {
17272            SectionError::Abort(
17273                "sync.invalid_request".to_owned(),
17274                format!("sqlite segment rejected: {detail} (§5.3)"),
17275            )
17276        };
17277
17278        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
17279        //    one row).
17280        type MetaRow = (i64, String, i64, i64, String, i64, i64);
17281        let meta: MetaRow = img
17282            .query_row(
17283                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
17284                        \"scopeDigest\", \"rowCount\",
17285                        (SELECT count(*) FROM _syncular_segment)
17286                 FROM _syncular_segment",
17287                [],
17288                |row| {
17289                    Ok((
17290                        row.get(0)?,
17291                        row.get(1)?,
17292                        row.get(2)?,
17293                        row.get(3)?,
17294                        row.get(4)?,
17295                        row.get(5)?,
17296                        row.get(6)?,
17297                    ))
17298                },
17299            )
17300            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
17301        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
17302        if meta_count != 1 {
17303            return Err(invalid(format!(
17304                "_syncular_segment must contain exactly one row, found {meta_count}"
17305            )));
17306        }
17307        if format != 1 {
17308            return Err(invalid(format!("format {format}")));
17309        }
17310        if meta_table != table.name {
17311            return Err(invalid(format!("image table {meta_table:?}")));
17312        }
17313        if schema_version != i64::from(self.schema.version) {
17314            return Err(invalid(format!("schemaVersion {schema_version}")));
17315        }
17316        if pin != as_of_commit_seq {
17317            return Err(invalid(format!("asOfCommitSeq {pin}")));
17318        }
17319        if digest != scope_digest {
17320            return Err(invalid("scopeDigest mismatch".to_owned()));
17321        }
17322        if meta_rows != row_count {
17323            return Err(invalid(format!("rowCount {meta_rows}")));
17324        }
17325
17326        // 2. Column names and order vs the generated schema (§5.3 rule 3).
17327        let mut names: Vec<String> = Vec::new();
17328        let mut primary_keys: Vec<String> = Vec::new();
17329        {
17330            let mut stmt = img
17331                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
17332                .map_err(|_| invalid("image data table missing".to_owned()))?;
17333            let mut rows = stmt
17334                .query([])
17335                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
17336            while let Some(row) = rows
17337                .next()
17338                .map_err(|_| invalid("image data table unreadable".to_owned()))?
17339            {
17340                if row
17341                    .get::<_, i64>(5)
17342                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
17343                    > 0
17344                {
17345                    primary_keys.push(row.get(1).map_err(|error| {
17346                        invalid(Self::sqlite_failure(&self.storage_failure, error))
17347                    })?);
17348                }
17349                names.push(
17350                    row.get::<_, String>(1)
17351                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
17352                );
17353            }
17354        }
17355        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
17356        expected.push("_syncular_version");
17357        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
17358            return Err(SectionError::Abort(
17359                "sync.schema_mismatch".to_owned(),
17360                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
17361            ));
17362        }
17363
17364        if primary_keys != vec![table.primary_key.clone()] {
17365            return Err(invalid(
17366                "image primary key does not match generated schema".into(),
17367            ));
17368        }
17369        let actual_count: i64 = img
17370            .query_row(
17371                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
17372                [],
17373                |row| row.get(0),
17374            )
17375            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17376        if actual_count != row_count {
17377            return Err(invalid("image row count does not match descriptor".into()));
17378        }
17379        let insert = self.insert_row_sql(&base_table(&table.name), table);
17380        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
17381        let column_list = names
17382            .iter()
17383            .map(|n| quote_ident(n))
17384            .collect::<Vec<_>>()
17385            .join(", ");
17386        let mut stmt = img
17387            .prepare(&format!(
17388                "SELECT {column_list} FROM {} ORDER BY {}",
17389                quote_ident(&table.name),
17390                quote_ident(&table.primary_key)
17391            ))
17392            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17393        let mut rows = stmt
17394            .query([])
17395            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17396        let mut applied = 0u32;
17397        loop {
17398            let clear = applied == 0 && first_fresh_page;
17399            let was_dirty = self.overlay_dirty.snapshot();
17400            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17401                && self
17402                    .outbox
17403                    .iter()
17404                    .flat_map(|commit| &commit.ops)
17405                    .any(|op| op.table == table.name);
17406            let mirror_visible = !reconcile_pending
17407                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17408            self.begin_observation("syncular_image_chunk")
17409                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17410            let result = (|| {
17411                self.rebuild_overlay_if_dirty()
17412                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17413                let mut changed_keys = Vec::new();
17414                let mut batch = ChangeAccumulator::default();
17415                if clear {
17416                    if self.scoped_rows_exist(&table.name, effective) {
17417                        batch.table(&table.name);
17418                    }
17419                    self.purge_scope_rows(&table.name, effective)
17420                        .map_err(|()| SectionError::FailClosed)?;
17421                }
17422                let mut processed = 0u32;
17423                {
17424                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
17425                        invalid(Self::sqlite_failure(&self.storage_failure, error))
17426                    })?;
17427                    let mut mirror = if mirror_visible {
17428                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
17429                            invalid(Self::sqlite_failure(&self.storage_failure, error))
17430                        })?)
17431                    } else {
17432                        None
17433                    };
17434                    for _ in 0..1024 {
17435                        let Some(row) = rows.next().map_err(|error| {
17436                            invalid(Self::sqlite_failure(&self.storage_failure, error))
17437                        })?
17438                        else {
17439                            break;
17440                        };
17441                        for (i, column) in table.columns.iter().enumerate() {
17442                            let cell = row.get_ref(i).map_err(|error| {
17443                                invalid(Self::sqlite_failure(&self.storage_failure, error))
17444                            })?;
17445                            let param = image_cell_param(column, cell).map_err(invalid)?;
17446                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
17447                                invalid(Self::sqlite_failure(&self.storage_failure, error))
17448                            })?;
17449                            if let Some(mirror) = &mut mirror {
17450                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
17451                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
17452                                })?;
17453                            }
17454                        }
17455                        let version_index = table.columns.len();
17456                        let version: i64 = row.get(version_index).map_err(|error| {
17457                            invalid(Self::sqlite_failure(&self.storage_failure, error))
17458                        })?;
17459                        if version < 1 {
17460                            return Err(invalid("image row version must be positive".into()));
17461                        }
17462                        ins.raw_bind_parameter(version_index + 1, version)
17463                            .map_err(|error| {
17464                                invalid(Self::sqlite_failure(&self.storage_failure, error))
17465                            })?;
17466                        ins.raw_execute().map_err(|error| {
17467                            invalid(Self::sqlite_failure(&self.storage_failure, error))
17468                        })?;
17469                        if let Some(mirror) = &mut mirror {
17470                            mirror
17471                                .raw_bind_parameter(version_index + 1, version)
17472                                .map_err(|error| {
17473                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
17474                                })?;
17475                            mirror.raw_execute().map_err(|error| {
17476                                invalid(Self::sqlite_failure(&self.storage_failure, error))
17477                            })?;
17478                        }
17479                        if reconcile_pending {
17480                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
17481                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
17482                            )?);
17483                        }
17484                        processed += 1;
17485                    }
17486                }
17487                if processed > 0 {
17488                    batch.table(&table.name);
17489                    self.overlay_dirty.table(&table.name);
17490                }
17491                if reconcile_pending {
17492                    self.reconcile_imported_rows(table, &mut changed_keys)
17493                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17494                }
17495                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17496                    self.apply_outbox_ops(
17497                        self.outbox
17498                            .iter()
17499                            .flat_map(|commit| &commit.ops)
17500                            .filter(|op| op.table == table.name),
17501                    )
17502                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17503                    self.overlay_dirty.set(false);
17504                } else {
17505                    self.rebuild_overlay_if_dirty()
17506                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17507                }
17508                self.finish_observation("syncular_image_chunk", batch)
17509                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17510                Ok(processed)
17511            })();
17512            let processed = match result {
17513                Ok(processed) => processed,
17514                Err(error) => {
17515                    self.rollback_observation("syncular_image_chunk");
17516                    self.overlay_dirty.restore(was_dirty.clone());
17517                    return Err(error);
17518                }
17519            };
17520            applied += processed;
17521            self.progress
17522                .update(|p| p.rows_processed = u64::from(applied));
17523            std::thread::yield_now();
17524            if i64::from(applied) == actual_count {
17525                break;
17526            }
17527        }
17528        Ok(applied)
17529    }
17530
17531    /// Restore only imported and pending row identities before FIFO replay. All pending
17532    /// identities participate: a server row can free a unique value for a different edit.
17533    /// The caller owns the chunk transaction and publishes its revision after replay.
17534    fn reconcile_imported_rows(
17535        &self,
17536        table: &TableSchema,
17537        keys: &mut Vec<SqlValue>,
17538    ) -> Result<(), String> {
17539        let visible = visible_table(&table.name);
17540        let base = base_table(&table.name);
17541        let pk = quote_ident(&table.primary_key);
17542        let predicate = row_id_predicate(table);
17543        let mut lookup = self.conn.prepare_cached(&format!(
17544            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
17545        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17546        let mut deleted = HashSet::new();
17547        for op in self
17548            .outbox
17549            .iter()
17550            .flat_map(|commit| &commit.ops)
17551            .filter(|op| op.table == table.name)
17552        {
17553            if op.upsert {
17554                if let Some(values) = &op.values {
17555                    let key = json_to_column_value(
17556                        &table.columns[table.pk_index],
17557                        values.get(&table.primary_key),
17558                    )?;
17559                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
17560                }
17561            } else if deleted.insert(&op.row_id) {
17562                keys.extend(
17563                    lookup
17564                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
17565                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
17566                        .collect::<Result<Vec<_>, _>>()
17567                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
17568                );
17569            }
17570        }
17571        // Delete the complete affected set before restoring any base value, so
17572        // old optimistic unique values cannot collide with the new server rows.
17573        for chunk in keys.chunks(400) {
17574            let holes = vec!["?"; chunk.len()].join(",");
17575            self.conn
17576                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
17577                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
17578                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17579        }
17580        let updates = table
17581            .columns
17582            .iter()
17583            .map(|column| column.name.as_str())
17584            .chain(std::iter::once("_syncular_version"))
17585            .filter(|name| *name != table.primary_key)
17586            .map(|name| {
17587                let name = quote_ident(name);
17588                format!("{name}=excluded.{name}")
17589            })
17590            .collect::<Vec<_>>()
17591            .join(",");
17592        for chunk in keys.chunks(400) {
17593            let holes = vec!["?"; chunk.len()].join(",");
17594            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
17595                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
17596                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17597        }
17598        Ok(())
17599    }
17600
17601    // -- application-authorized local purge ---------------------------------------
17602
17603    fn compile_local_data_purge(
17604        &self,
17605        input: &LocalDataPurgeInput,
17606    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
17607        let invalid = |message: String| format!("sync.invalid_request: {message}");
17608        if input.purge_id.is_empty()
17609            || input.purge_id.len() > 128
17610            || !is_local_operation_code_like(&input.purge_id)
17611        {
17612            return Err(invalid(
17613                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
17614            ));
17615        }
17616        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
17617            return Err(invalid(format!(
17618                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
17619            )));
17620        }
17621
17622        let mut deduplicated: BTreeMap<
17623            String,
17624            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
17625        > = BTreeMap::new();
17626        for target in &input.targets {
17627            let table = self.schema.table(&target.table).ok_or_else(|| {
17628                invalid(format!(
17629                    "local purge names unknown table {:?}",
17630                    target.table
17631                ))
17632            })?;
17633            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
17634                return Err(invalid(format!(
17635                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
17636                    target.table
17637                )));
17638            }
17639            let mut selectors = Vec::with_capacity(target.selectors.len());
17640            let mut canonical_selectors = BTreeMap::new();
17641            for (column_name, raw_values) in &target.selectors {
17642                let Some((column_index, column)) = table
17643                    .columns
17644                    .iter()
17645                    .enumerate()
17646                    .find(|(_, column)| column.name == *column_name)
17647                else {
17648                    return Err(invalid(format!(
17649                        "local purge target {:?} names unknown column {:?}",
17650                        target.table, column_name
17651                    )));
17652                };
17653                let encrypted = table
17654                    .encrypted_columns
17655                    .iter()
17656                    .any(|candidate| candidate.index == column_index);
17657                if column.ty != ColumnType::String || encrypted {
17658                    return Err(invalid(format!(
17659                        "local purge selector {:?}.{:?} must be a plaintext string column",
17660                        target.table, column_name
17661                    )));
17662                }
17663                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
17664                    return Err(invalid(format!(
17665                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
17666                        target.table, column_name
17667                    )));
17668                }
17669                let mut values = raw_values.clone();
17670                values.sort();
17671                values.dedup();
17672                if values.iter().any(|value| {
17673                    value.is_empty()
17674                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
17675                        || !is_local_operation_code_like(value)
17676                }) {
17677                    return Err(invalid(format!(
17678                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
17679                    )));
17680                }
17681                selectors.push((column_name.clone(), values.clone()));
17682                canonical_selectors.insert(column_name.clone(), values);
17683            }
17684            let canonical = LocalDataPurgeTarget {
17685                table: target.table.clone(),
17686                selectors: canonical_selectors,
17687            };
17688            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
17689            deduplicated.insert(
17690                key,
17691                (
17692                    CompiledLocalDataPurgeTarget {
17693                        table: target.table.clone(),
17694                        selectors,
17695                    },
17696                    canonical,
17697                ),
17698            );
17699        }
17700        let targets = deduplicated
17701            .values()
17702            .map(|(compiled, _)| compiled.clone())
17703            .collect::<Vec<_>>();
17704        let canonical = deduplicated
17705            .values()
17706            .map(|(_, target)| target.clone())
17707            .collect::<Vec<_>>();
17708        let canonical_plan =
17709            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
17710        Ok((targets, canonical_plan))
17711    }
17712
17713    fn local_purge_base_row_ids(
17714        &self,
17715        targets: &[CompiledLocalDataPurgeTarget],
17716    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
17717        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
17718        for target in targets {
17719            let table = self
17720                .schema
17721                .table(&target.table)
17722                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
17723            let mut clauses = Vec::with_capacity(target.selectors.len());
17724            let mut params = Vec::new();
17725            for (column, values) in &target.selectors {
17726                clauses.push(format!(
17727                    "{} IN ({})",
17728                    quote_ident(column),
17729                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
17730                ));
17731                params.extend(values.iter().cloned().map(SqlValue::Text));
17732            }
17733            let sql = format!(
17734                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
17735                quote_ident(&table.primary_key),
17736                base_table(&target.table),
17737                clauses.join(" AND ")
17738            );
17739            let mut statement = self
17740                .conn
17741                .prepare(&sql)
17742                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17743            let rows = statement
17744                .query_map(rusqlite::params_from_iter(params), |row| {
17745                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
17746                    render_row_id_json(Some(&value))
17747                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
17748                })
17749                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17750            let ids = by_table.entry(target.table.clone()).or_default();
17751            for row in rows {
17752                ids.insert(
17753                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
17754                );
17755            }
17756        }
17757        Ok(by_table)
17758    }
17759
17760    fn local_purge_values_match(
17761        target: &CompiledLocalDataPurgeTarget,
17762        values: &Map<String, Value>,
17763    ) -> bool {
17764        target.selectors.iter().all(|(column, allowed)| {
17765            values
17766                .get(column)
17767                .and_then(Value::as_str)
17768                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
17769        })
17770    }
17771
17772    /// Apply one host-authorized local security purge. The host MUST gate the
17773    /// corresponding subscriptions first; this primitive owns local SQLite
17774    /// cleanup and never grants/revokes server authority by itself.
17775    pub fn purge_local_data(
17776        &mut self,
17777        input: &LocalDataPurgeInput,
17778    ) -> Result<LocalDataPurgeResult, String> {
17779        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
17780        let meta_key = format!("localPurge:{}", input.purge_id);
17781        let applied_plan = self
17782            .conn
17783            .query_row(
17784                "SELECT value FROM _syncular_meta WHERE key = ?1",
17785                rusqlite::params![meta_key],
17786                |row| row.get::<_, String>(0),
17787            )
17788            .optional()
17789            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17790        if let Some(applied_plan) = applied_plan {
17791            if applied_plan != canonical_plan {
17792                return Err(format!(
17793                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
17794                    input.purge_id
17795                ));
17796            }
17797            return Ok(LocalDataPurgeResult {
17798                already_applied: true,
17799                purged_rows: 0,
17800                dropped_commits: 0,
17801            });
17802        }
17803
17804        // RFC 0005 D7: the container file cannot be a purge target (its name is
17805        // not in the running schema), so purge drops it as a fixed step, before
17806        // any mirror row is touched and unconditionally.
17807        let _ = self.drop_previous_version();
17808
17809        let prior_outbox = self.outbox.clone();
17810        let prior_failed = self.failed_commits.clone();
17811        let prior_rejections = self.rejections.clone();
17812        let prior_conflicts = self.conflicts.clone();
17813        let prior_overlay_dirty = self.overlay_dirty.snapshot();
17814        self.begin_observation("syncular_local_purge")?;
17815        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
17816            let row_ids = self.local_purge_base_row_ids(&targets)?;
17817            let retained_dropped = self.drop_failed_commits(|table, values| {
17818                targets.iter().any(|target| {
17819                    target.table == table && Self::local_purge_values_match(target, values)
17820                })
17821            })?;
17822            let doomed = self
17823                .outbox
17824                .iter()
17825                .filter(|commit| {
17826                    commit.ops.iter().any(|operation| {
17827                        let matching_targets = targets
17828                            .iter()
17829                            .filter(|target| target.table == operation.table)
17830                            .collect::<Vec<_>>();
17831                        if matching_targets.is_empty() {
17832                            return false;
17833                        }
17834                        if row_ids
17835                            .get(&operation.table)
17836                            .is_some_and(|ids| ids.contains(&operation.row_id))
17837                        {
17838                            return true;
17839                        }
17840                        operation.values.as_ref().is_some_and(|values| {
17841                            matching_targets
17842                                .iter()
17843                                .any(|target| Self::local_purge_values_match(target, values))
17844                        })
17845                    })
17846                })
17847                .cloned()
17848                .collect::<Vec<_>>();
17849            let doomed_ids = doomed
17850                .iter()
17851                .map(|commit| commit.client_commit_id.clone())
17852                .collect::<BTreeSet<_>>();
17853            let mut batch = ChangeAccumulator::default();
17854            for commit in &retained_dropped {
17855                batch.conflicts = true;
17856                batch.rejections = true;
17857                batch.outcomes = true;
17858                for operation in &commit.ops {
17859                    batch.table(&operation.table);
17860                }
17861            }
17862            let mut rejections = Vec::new();
17863            for commit in &doomed {
17864                for operation in &commit.ops {
17865                    batch.table(&operation.table);
17866                }
17867                let results = commit
17868                    .ops
17869                    .iter()
17870                    .enumerate()
17871                    .map(|(op_index, operation)| {
17872                        let rejection = RejectionRecord {
17873                            client_commit_id: commit.client_commit_id.clone(),
17874                            op_index: op_index as i32,
17875                            code: "client.local_data_purged".to_owned(),
17876                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
17877                            retryable: false,
17878                            details: None,
17879                            operation: Some(CommitOperation::from(operation)),
17880                        };
17881                        rejections.push(rejection.clone());
17882                        CommitOperationOutcome::Error { rejection }
17883                    })
17884                    .collect::<Vec<_>>();
17885                self.persist_commit_outcome(
17886                    &commit.client_commit_id,
17887                    CommitOutcomeStatus::Rejected,
17888                    &results,
17889                    Some(&commit.ops),
17890                )?;
17891                self.delete_outbox_persisted(&commit.client_commit_id)?;
17892            }
17893            if !doomed.is_empty() {
17894                self.outbox
17895                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
17896                self.rejections.extend(rejections);
17897                self.prune_commit_outcomes()?;
17898                batch.status = true;
17899                batch.rejections = true;
17900                batch.outcomes = true;
17901            }
17902
17903            let mut purged_rows = 0usize;
17904            for (table, ids) in &row_ids {
17905                if ids.is_empty() {
17906                    continue;
17907                }
17908                batch.table(table);
17909                for row_id in ids {
17910                    self.delete_base_row(table, row_id)?;
17911                    purged_rows += 1;
17912                }
17913            }
17914            self.rebuild_overlay_if_dirty()?;
17915            self.delete_unreferenced_cached_blobs()?;
17916            self.conn
17917                .execute(
17918                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
17919                    rusqlite::params![meta_key, canonical_plan],
17920                )
17921                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17922            Ok((
17923                batch,
17924                LocalDataPurgeResult {
17925                    already_applied: false,
17926                    purged_rows,
17927                    dropped_commits: doomed.len(),
17928                },
17929            ))
17930        })();
17931        let (batch, result) = match applied {
17932            Ok(value) => value,
17933            Err(error) => {
17934                self.rollback_observation("syncular_local_purge");
17935                self.outbox = prior_outbox;
17936                self.failed_commits = prior_failed;
17937                self.rejections = prior_rejections;
17938                self.conflicts = prior_conflicts;
17939                self.overlay_dirty.restore(prior_overlay_dirty.clone());
17940                return Err(error);
17941            }
17942        };
17943        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
17944            self.rollback_observation("syncular_local_purge");
17945            self.outbox = prior_outbox;
17946            self.failed_commits = prior_failed;
17947            self.rejections = prior_rejections;
17948            self.conflicts = prior_conflicts;
17949            self.overlay_dirty.restore(prior_overlay_dirty.clone());
17950            return Err(error);
17951        }
17952        self.cancel_sync_round();
17953        Ok(result)
17954    }
17955
17956    /// Application-authorized recovery of the replicated projection. Unlike a
17957    /// local security purge, this retains the entire outbox, device identity,
17958    /// lease, outcomes, subscription registrations, and protected bookkeeping.
17959    /// The projection reset, subscription rewind, optimistic replay, and
17960    /// idempotency marker share one savepoint for interruption safety.
17961    pub fn rebootstrap_local_data(
17962        &mut self,
17963        input: &LocalDataRebootstrapInput,
17964    ) -> Result<LocalDataRebootstrapResult, String> {
17965        if input.rebootstrap_id.is_empty()
17966            || input.rebootstrap_id.len() > 128
17967            || !is_local_operation_code_like(&input.rebootstrap_id)
17968        {
17969            return Err(
17970                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
17971                    .to_owned(),
17972            );
17973        }
17974        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
17975        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
17976            let (retained_commits, reset_subscriptions) =
17977                decode_local_rebootstrap_receipt(&persisted)?;
17978            return Ok(LocalDataRebootstrapResult {
17979                already_applied: true,
17980                retained_commits,
17981                reset_subscriptions,
17982            });
17983        }
17984        if self.stopped || self.schema_floor.is_some() {
17985            return Err(
17986                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
17987                    .to_owned(),
17988            );
17989        }
17990
17991        let retained_commits = self.outbox.len();
17992        let reset_subscriptions = self.subs.len();
17993        let prior_subs = self.subs.clone();
17994        let prior_upgrading = self.upgrading;
17995        let prior_stopped = self.stopped;
17996        let prior_schema_floor = self.schema_floor.clone();
17997        let prior_overlay_dirty = self.overlay_dirty.snapshot();
17998        let prior_sync_needed = self.sync_needed;
17999        let prior_sync_intents = self.sync_intent_queue.clone();
18000        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
18001
18002        let prior_active_round = self.active_round;
18003        self.begin_observation("syncular_local_rebootstrap")?;
18004        let mut batch = ChangeAccumulator::default();
18005        let applied = (|| -> Result<(), String> {
18006            self.run_schema_reset_observed(&mut batch, false)?;
18007            self.conn
18008                .execute(
18009                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
18010                    rusqlite::params![meta_key, receipt],
18011                )
18012                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18013            self.sync_needed = true;
18014            self.sync_intent_queue.push_back(SyncIntent::Interactive);
18015            batch.status = true;
18016            Ok(())
18017        })();
18018        if let Err(error) = applied {
18019            self.rollback_observation("syncular_local_rebootstrap");
18020            self.active_round = prior_active_round;
18021            self.subs = prior_subs;
18022            self.upgrading = prior_upgrading;
18023            self.stopped = prior_stopped;
18024            self.schema_floor = prior_schema_floor;
18025            self.overlay_dirty.restore(prior_overlay_dirty.clone());
18026            self.sync_needed = prior_sync_needed;
18027            self.sync_intent_queue = prior_sync_intents;
18028            return Err(error);
18029        }
18030        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
18031            self.rollback_observation("syncular_local_rebootstrap");
18032            self.active_round = prior_active_round;
18033            self.subs = prior_subs;
18034            self.upgrading = prior_upgrading;
18035            self.stopped = prior_stopped;
18036            self.schema_floor = prior_schema_floor;
18037            self.overlay_dirty.restore(prior_overlay_dirty.clone());
18038            self.sync_needed = prior_sync_needed;
18039            self.sync_intent_queue = prior_sync_intents;
18040            return Err(error);
18041        }
18042
18043        Ok(LocalDataRebootstrapResult {
18044            already_applied: false,
18045            retained_commits,
18046            reset_subscriptions,
18047        })
18048    }
18049
18050    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
18051
18052    /// Delete base rows matching the effective scopes; `Err(())` = no local
18053    /// scope-column mapping for a key (the fail-closed case).
18054    fn purge_scope_rows(
18055        &mut self,
18056        table_name: &str,
18057        effective: &[(String, Vec<String>)],
18058    ) -> Result<(), ()> {
18059        if effective.is_empty() {
18060            return Ok(());
18061        }
18062        let table = self.schema.table(table_name).ok_or(())?.clone();
18063        let mut clauses = Vec::new();
18064        let mut params: Vec<SqlValue> = Vec::new();
18065        for (variable, values) in effective {
18066            let column = table.scope_column(variable).ok_or(())?;
18067            let placeholders: Vec<String> = values
18068                .iter()
18069                .map(|v| {
18070                    params.push(SqlValue::Text(v.clone()));
18071                    "?".to_owned()
18072                })
18073                .collect();
18074            clauses.push(format!(
18075                "{} IN ({})",
18076                quote_ident(column),
18077                placeholders.join(", ")
18078            ));
18079        }
18080        let sql = format!(
18081            "DELETE FROM {} WHERE {}",
18082            base_table(table_name),
18083            clauses.join(" AND ")
18084        );
18085        self.overlay_dirty.table(table_name);
18086        self.conn
18087            .execute(&sql, rusqlite::params_from_iter(&params))
18088            .map_err(|_| ())?;
18089        self.conn
18090            .execute(
18091                &format!(
18092                    "DELETE FROM {} WHERE {}",
18093                    visible_table(table_name),
18094                    clauses.join(" AND ")
18095                ),
18096                rusqlite::params_from_iter(&params),
18097            )
18098            .map_err(|_| ())?;
18099        Ok(())
18100    }
18101
18102    /// §3.3: drop pending commits whose upserts provably land in the
18103    /// revoked effective scopes — whole-commit, never per-operation.
18104    fn drop_doomed_outbox(
18105        &mut self,
18106        table_name: &str,
18107        effective: &[(String, Vec<String>)],
18108    ) -> Result<bool, String> {
18109        if effective.is_empty() {
18110            return Ok(false);
18111        }
18112        let Some(table) = self.schema.table(table_name).cloned() else {
18113            return Ok(false);
18114        };
18115        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
18116        for (variable, values) in effective {
18117            match table.scope_column(variable) {
18118                Some(column) => mappings.push((column, values)),
18119                None => return Ok(false), // not provable without a mapping
18120            }
18121        }
18122        let doomed: Vec<OutboxCommit> = self
18123            .outbox
18124            .iter()
18125            .filter(|commit| {
18126                commit.ops.iter().any(|op| {
18127                    op.upsert
18128                        && op.table == table_name
18129                        && op.values.as_ref().is_some_and(|values| {
18130                            mappings
18131                                .iter()
18132                                .all(|(column, allowed)| match values.get(*column) {
18133                                    Some(Value::String(s)) => allowed.contains(s),
18134                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
18135                                    _ => false,
18136                                })
18137                        })
18138                })
18139            })
18140            .cloned()
18141            .collect();
18142        if doomed.is_empty() {
18143            return Ok(false);
18144        }
18145        let mut rejections = Vec::new();
18146        for commit in &doomed {
18147            let results = commit
18148                .ops
18149                .iter()
18150                .enumerate()
18151                .map(|(op_index, operation)| {
18152                    let rejection = RejectionRecord {
18153                        client_commit_id: commit.client_commit_id.clone(),
18154                        op_index: op_index as i32,
18155                        code: "sync.scope_revoked".to_owned(),
18156                        message: "the commit was dropped because its effective scope was revoked"
18157                            .to_owned(),
18158                        retryable: false,
18159                        details: None,
18160                        operation: Some(CommitOperation::from(operation)),
18161                    };
18162                    rejections.push(rejection.clone());
18163                    CommitOperationOutcome::Error { rejection }
18164                })
18165                .collect::<Vec<_>>();
18166            self.persist_commit_outcome(
18167                &commit.client_commit_id,
18168                CommitOutcomeStatus::Rejected,
18169                &results,
18170                Some(&commit.ops),
18171            )?;
18172            self.delete_outbox_persisted(&commit.client_commit_id)?;
18173        }
18174        self.prune_commit_outcomes()?;
18175        let doomed_ids = doomed
18176            .iter()
18177            .map(|commit| commit.client_commit_id.as_str())
18178            .collect::<BTreeSet<_>>();
18179        self.outbox
18180            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
18181        self.rejections.extend(rejections);
18182        Ok(true)
18183    }
18184
18185    // -- blobs (§5.9) ----------------------------------------------------------------
18186
18187    /// §5.9.7: hash bytes into the content address, cache them, queue the
18188    /// upload (flushed before the next push, B4). Returns the canonical
18189    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
18190    /// column value.
18191    pub fn upload_blob(
18192        &mut self,
18193        bytes: &[u8],
18194        media_type: Option<String>,
18195        name: Option<String>,
18196    ) -> Result<Value, String> {
18197        let blob_id = blob_id_for(bytes);
18198        let now = self.clock_now_ms();
18199        let tx = self
18200            .conn
18201            .transaction()
18202            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18203        tx.execute(
18204            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
18205             ON CONFLICT(blob_id) DO NOTHING",
18206            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
18207        )
18208        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18209        tx.execute(
18210            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
18211            rusqlite::params![blob_id, media_type, now],
18212        )
18213        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18214        tx.commit()
18215            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18216        self.enforce_blob_cache_cap();
18217        let mut obj = Map::new();
18218        obj.insert("blobId".to_owned(), Value::from(blob_id));
18219        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
18220        if let Some(mt) = media_type {
18221            obj.insert("mediaType".to_owned(), Value::from(mt));
18222        }
18223        if let Some(n) = name {
18224            obj.insert("name".to_owned(), Value::from(n));
18225        }
18226        Ok(Value::Object(obj))
18227    }
18228
18229    /// §5.9.7: resolve an owned blob body without driver encoding. A
18230    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
18231    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
18232    pub fn fetch_blob_bytes(
18233        &mut self,
18234        transport: &mut dyn Transport,
18235        blob_id_or_ref: &str,
18236    ) -> Result<FetchedBlob, (String, String)> {
18237        let simple = |m: String| ("client.failed".to_owned(), m);
18238        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
18239            blob_id_or_ref.to_owned()
18240        } else {
18241            let value: Value = serde_json::from_str(blob_id_or_ref)
18242                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
18243            value
18244                .get("blobId")
18245                .and_then(Value::as_str)
18246                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
18247                .to_owned()
18248        };
18249        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
18250            return Ok(cached);
18251        }
18252        if !self.transport_enabled {
18253            return Err(("sync.offline".into(), "transport gate is closed".into()));
18254        }
18255        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
18256        // blob.not_found) verbatim so the harness can assert on it. The
18257        // authorized endpoint serves bytes inline OR (always-issue, presign
18258        // configured) a signed url the client fetches directly — no host auth,
18259        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
18260        #[cfg(feature = "bench-internals")]
18261        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
18262        let bytes = match transport
18263            .blob_download(&blob_id)
18264            .map_err(|e| (e.code, e.message))?
18265        {
18266            BlobDownload::Bytes(bytes) => bytes,
18267            BlobDownload::Url {
18268                url,
18269                url_expires_at_ms,
18270            } => {
18271                // §5.9.5: MUST NOT start a fetch at/past expiry.
18272                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
18273                    return Err((
18274                        "sync.segment_expired".to_owned(),
18275                        format!(
18276                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
18277                        ),
18278                    ));
18279                }
18280                transport
18281                    .fetch_blob_url(&url)
18282                    .map_err(|e| (e.code, e.message))?
18283            }
18284        };
18285        #[cfg(feature = "bench-internals")]
18286        drop(download_phase);
18287        #[cfg(feature = "bench-internals")]
18288        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
18289        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
18290        if blob_id_for(&bytes) != blob_id {
18291            return Err(simple(format!(
18292                "blob content address mismatch for {blob_id}"
18293            )));
18294        }
18295        #[cfg(feature = "bench-internals")]
18296        drop(validate_phase);
18297        #[cfg(feature = "bench-internals")]
18298        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
18299        let now = self.clock_now_ms();
18300        self.conn
18301            .execute(
18302                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
18303                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
18304            )
18305            .map_err(|e| simple(e.to_string()))?;
18306        self.conn
18307            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
18308            .map_err(|e| simple(e.to_string()))?;
18309        self.enforce_blob_cache_cap();
18310        #[cfg(feature = "bench-internals")]
18311        drop(insert_phase);
18312        self.get_cached_blob_bytes(&blob_id)
18313            .map_err(simple)?
18314            .ok_or_else(|| simple("blob cache write failed".to_owned()))
18315    }
18316
18317    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
18318        #[cfg(feature = "bench-internals")]
18319        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
18320        let mut stmt = self
18321            .conn
18322            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
18323            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18324        let mut rows = stmt
18325            .query(rusqlite::params![blob_id])
18326            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18327        if let Some(row) = rows
18328            .next()
18329            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18330        {
18331            let bytes: Vec<u8> = row
18332                .get(0)
18333                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18334            let byte_length: i64 = row
18335                .get(1)
18336                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18337            let media_type: Option<String> = row
18338                .get(2)
18339                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18340            return Ok(Some(FetchedBlob {
18341                blob_id: blob_id.to_owned(),
18342                byte_length,
18343                bytes,
18344                media_type,
18345            }));
18346        }
18347        Ok(None)
18348    }
18349
18350    /// §5.9.7 B4: upload every queued blob before push.
18351    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
18352        let pending: Vec<(String, Option<String>)> = {
18353            let mut stmt = self
18354                .conn
18355                .prepare(
18356                    "SELECT blob_id, media_type FROM (
18357                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
18358                      UNION ALL
18359                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
18360                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
18361                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
18362                    ) ORDER BY created_at_ms, blob_id",
18363                )
18364                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18365            let rows = stmt
18366                .query_map([], |row| {
18367                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
18368                })
18369                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18370            rows.collect::<Result<_, _>>()
18371                .map_err(|error| match error {
18372                    rusqlite::Error::InvalidColumnType(..)
18373                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
18374                        "sync.local_corrupt",
18375                        "Pending blob upload metadata is invalid",
18376                    ),
18377                    _ => TransportError::new("client.failed", error.to_string()),
18378                })?
18379        };
18380        let mut uploads = Vec::new();
18381        for (blob_id, media_type) in pending {
18382            let bytes = (|| {
18383                let stored: Option<(SqlValue, SqlValue)> = self
18384                    .conn
18385                    .query_row(
18386                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
18387                        rusqlite::params![blob_id],
18388                        |row| Ok((row.get(0)?, row.get(1)?)),
18389                    )
18390                    .optional()
18391                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18392                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
18393                    return Err(TransportError::new(
18394                        "sync.local_corrupt",
18395                        "Pending blob upload body is missing or corrupt",
18396                    ));
18397                };
18398                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
18399                    return Err(TransportError::new(
18400                        "sync.local_corrupt",
18401                        "Pending blob upload body is missing or corrupt",
18402                    ));
18403                }
18404                Ok(bytes)
18405            })();
18406            let failed = bytes.is_err();
18407            uploads.push(crate::round::PendingUpload {
18408                id: blob_id,
18409                bytes,
18410                media_type,
18411            });
18412            if failed {
18413                break;
18414            }
18415        }
18416        Ok(uploads)
18417    }
18418
18419    #[cfg(test)]
18420    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
18421        for upload in self.pending_blob_uploads()? {
18422            self.upload_one(
18423                transport,
18424                &upload.id,
18425                &upload.bytes?,
18426                upload.media_type.as_deref(),
18427            )?;
18428            self.conn
18429                .execute(
18430                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
18431                    rusqlite::params![upload.id],
18432                )
18433                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18434        }
18435        Ok(())
18436    }
18437
18438    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
18439    /// grant when the transport supports it, else streaming through the direct
18440    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
18441    /// PUTs direct with no host auth; on a grant PUT failure the client streams
18442    /// through the direct endpoint — a *different, host-authenticated
18443    /// capability*, not a fall-through of the grant's authority.
18444    #[cfg(test)]
18445    fn upload_one(
18446        &self,
18447        transport: &mut dyn Transport,
18448        blob_id: &str,
18449        bytes: &[u8],
18450        media_type: Option<&str>,
18451    ) -> Result<(), TransportError> {
18452        crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
18453    }
18454
18455    fn visible_blob_ids_sql(&self) -> String {
18456        let mut selects = Vec::new();
18457        for table in &self.schema.tables {
18458            for column in table
18459                .columns
18460                .iter()
18461                .filter(|column| column.ty == ColumnType::BlobRef)
18462            {
18463                let identifier = quote_ident(&column.name);
18464                selects.push(format!(
18465                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
18466                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
18467                     AND json_type({identifier}, '$.blobId') = 'text'",
18468                    quote_ident(&table.name),
18469                ));
18470            }
18471        }
18472        if selects.is_empty() {
18473            "SELECT NULL AS blob_id WHERE 0".to_owned()
18474        } else {
18475            selects.join(" UNION ")
18476        }
18477    }
18478
18479    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
18480    /// staged uploads pin bodies directly; no derived refcount is stored.
18481    fn enforce_blob_cache_cap(&self) {
18482        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
18483            return;
18484        };
18485        let mut total: i64 = self
18486            .conn
18487            .query_row(
18488                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
18489                [],
18490                |row| row.get(0),
18491            )
18492            .unwrap_or(0);
18493        if total <= max_bytes {
18494            return;
18495        }
18496        let query = format!(
18497            "SELECT blob_id, byte_length FROM _syncular_blobs
18498             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
18499               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
18500               AND blob_id NOT IN ({})
18501             ORDER BY created_at_ms ASC, blob_id ASC",
18502            self.visible_blob_ids_sql(),
18503        );
18504        let candidates: Vec<(String, i64)> = {
18505            let Ok(mut stmt) = self.conn.prepare(&query) else {
18506                return;
18507            };
18508            let Ok(rows) = stmt.query_map([], |row| {
18509                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
18510            }) else {
18511                return;
18512            };
18513            rows.filter_map(Result::ok).collect()
18514        };
18515        for (blob_id, byte_length) in candidates {
18516            if total <= max_bytes {
18517                break;
18518            }
18519            let _ = self.conn.execute(
18520                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
18521                rusqlite::params![blob_id],
18522            );
18523            total -= byte_length;
18524        }
18525    }
18526
18527    fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
18528        #[cfg(feature = "bench-internals")]
18529        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
18530        if !self.schema_has_blobs() {
18531            return Ok(());
18532        }
18533        self.rebuild_overlay_if_dirty()?;
18534        self.conn
18535            .execute(
18536                &format!(
18537                    "DELETE FROM _syncular_blobs
18538                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
18539                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
18540                   AND blob_id NOT IN ({})",
18541                    self.visible_blob_ids_sql(),
18542                ),
18543                [],
18544            )
18545            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18546        Ok(())
18547    }
18548
18549    // -- local row storage ----------------------------------------------------------
18550
18551    /// The cached per-table primary-key upsert SQL for `full_table` (see
18552    /// the `insert_sql` field: built once, reused per row).
18553    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
18554        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
18555            return sql.clone();
18556        }
18557        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
18558        columns.push(quote_ident("_syncular_version"));
18559        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
18560        let primary_key = quote_ident(&table.primary_key);
18561        let updates = columns
18562            .iter()
18563            .filter(|column| **column != primary_key)
18564            .map(|column| format!("{column}=excluded.{column}"))
18565            .collect::<Vec<_>>()
18566            .join(", ");
18567        let sql = format!(
18568            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
18569            columns.join(", "),
18570            placeholders.join(", ")
18571        );
18572        self.insert_sql
18573            .borrow_mut()
18574            .insert(full_table.to_owned(), sql.clone());
18575        sql
18576    }
18577
18578    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
18579        self.overlay_dirty.table(table_name);
18580        self.write_row(&base_table(table_name), table_name, row, version)
18581    }
18582
18583    fn write_row(
18584        &self,
18585        full_table: &str,
18586        table_name: &str,
18587        row: &Row,
18588        version: i64,
18589    ) -> Result<(), String> {
18590        #[cfg(feature = "bench-internals")]
18591        let _phase = self.benchmark_phases.start(Phase::RowWrite);
18592        let table = self
18593            .schema
18594            .table(table_name)
18595            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
18596        let sql = self.insert_row_sql(full_table, table);
18597        let mut stmt = self
18598            .conn
18599            .prepare_cached(&sql)
18600            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18601        let params = row
18602            .iter()
18603            .map(RowParam::Cell)
18604            .chain(std::iter::once(RowParam::Version(version)));
18605        stmt.execute(rusqlite::params_from_iter(params))
18606            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18607        Ok(())
18608    }
18609
18610    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
18611        let table = self
18612            .schema
18613            .table(table_name)
18614            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
18615        self.overlay_dirty.table(table_name);
18616        let sql = format!(
18617            "DELETE FROM {} WHERE {}",
18618            base_table(table_name),
18619            row_id_predicate(table)
18620        );
18621        let mut stmt = self
18622            .conn
18623            .prepare_cached(&sql)
18624            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18625        stmt.execute(rusqlite::params![row_id])
18626            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18627        Ok(())
18628    }
18629
18630    fn has_acknowledged_rows(&self) -> bool {
18631        self.conn
18632            .query_row(
18633                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
18634                [],
18635                |row| row.get(0),
18636            )
18637            .expect("read acknowledged intent")
18638    }
18639
18640    fn acknowledged_operations(
18641        &self,
18642        rows: Option<&HashSet<(&str, &str)>>,
18643        tables: Option<&BTreeSet<&str>>,
18644    ) -> Result<Vec<OutboxOp>, String> {
18645        let keys = rows.map(|rows| {
18646            serde_json::to_string(
18647                &rows
18648                    .iter()
18649                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
18650                    .collect::<Vec<_>>(),
18651            )
18652            .expect("row keys")
18653        });
18654        let mut statement = self
18655            .conn
18656            .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")
18657            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18658        let rows = statement
18659            .query_map(
18660                rusqlite::params![
18661                    keys,
18662                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
18663                ],
18664                |row| {
18665                    #[cfg(test)]
18666                    self.acknowledged_replay_count
18667                        .set(self.acknowledged_replay_count.get() + 1);
18668                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18669                },
18670            )
18671            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18672        rows.map(|row| {
18673            let (op, intent) =
18674                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18675            let op: CommitOperation =
18676                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
18677            let table = self
18678                .schema
18679                .table(&op.table)
18680                .ok_or_else(|| "sync.unknown_table".to_owned())?;
18681            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
18682                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
18683            } else {
18684                op.values
18685            };
18686            Ok(OutboxOp {
18687                table: op.table,
18688                row_id: op.row_id,
18689                upsert: op.op == "upsert",
18690                base_version: op.base_version,
18691                values,
18692            })
18693        })
18694        .collect()
18695    }
18696
18697    fn drop_acknowledged_rows(
18698        &self,
18699        matches: impl Fn(&str, &Map<String, Value>) -> bool,
18700    ) -> Result<Vec<OutboxCommit>, String> {
18701        let mut statement = self
18702            .conn
18703            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
18704            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18705        let rows = statement
18706            .query_map([], |row| {
18707                Ok((
18708                    row.get::<_, String>(0)?,
18709                    row.get::<_, String>(1)?,
18710                    row.get::<_, String>(2)?,
18711                    row.get::<_, String>(3)?,
18712                ))
18713            })
18714            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18715        let mut doomed = HashSet::new();
18716        for row in rows {
18717            let (id, table, intent, row_id) =
18718                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18719            let values =
18720                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
18721            let base_matches = self
18722                .schema
18723                .table(&table)
18724                .map(|schema| self.stored_row(schema, &row_id, true))
18725                .transpose()?
18726                .flatten()
18727                .is_some_and(|(base, _)| {
18728                    let schema = self.schema.table(&table).expect("acknowledged table");
18729                    let values = schema
18730                        .columns
18731                        .iter()
18732                        .zip(base.iter())
18733                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
18734                        .collect();
18735                    matches(&table, &values)
18736                });
18737            if matches(&table, &values) || base_matches {
18738                doomed.insert(id);
18739            }
18740        }
18741        let mut dropped = Vec::new();
18742        for id in doomed {
18743            let mut statement = self
18744                .conn
18745                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
18746                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18747            let operations = statement
18748                .query_map([&id], |row| row.get::<_, String>(0))
18749                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18750            let mut ops = Vec::new();
18751            for operation in operations {
18752                let json = operation
18753                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18754                let op: CommitOperation =
18755                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
18756                self.overlay_dirty.table(&op.table);
18757                ops.push(OutboxOp {
18758                    table: op.table,
18759                    row_id: op.row_id,
18760                    upsert: op.op == "upsert",
18761                    base_version: op.base_version,
18762                    values: op.values,
18763                });
18764            }
18765            dropped.push(OutboxCommit {
18766                client_commit_id: id.clone(),
18767                ops,
18768            });
18769            self.conn
18770                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
18771                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18772        }
18773        Ok(dropped)
18774    }
18775
18776    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
18777    /// the outbox changed since the last rebuild (the no-op sync round).
18778    fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
18779        if self.overlay_dirty.get() {
18780            self.rebuild_overlay()?;
18781        }
18782        Ok(())
18783    }
18784
18785    /// §7.1: local reads see outbox state applied optimistically — rebuild
18786    /// every visible table as (base server state) + (pending outbox replay
18787    /// on top). Optimistic rows carry version `-1`.
18788    fn rebuild_overlay(&mut self) -> Result<(), String> {
18789        #[cfg(feature = "bench-internals")]
18790        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
18791        #[cfg(test)]
18792        self.overlay_rebuild_count
18793            .set(self.overlay_rebuild_count.get() + 1);
18794        let dirty = self.overlay_dirty.snapshot();
18795        let tables: Vec<_> = self
18796            .schema
18797            .tables
18798            .iter()
18799            .filter(|table| {
18800                dirty
18801                    .as_ref()
18802                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
18803            })
18804            .cloned()
18805            .collect();
18806        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
18807        self.begin_observation("syncular_overlay")?;
18808        let rebuilt = (|| {
18809            for table in &tables {
18810                for index in &table.fts_indexes {
18811                    self.drop_fts_triggers(index)?;
18812                }
18813                let visible = visible_table(&table.name);
18814                let base = base_table(&table.name);
18815                self.conn
18816                    .execute_batch(&format!(
18817                        "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
18818                    ))
18819                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18820            }
18821            let acknowledged = self.acknowledged_operations(None, Some(&names))?;
18822            self.apply_outbox_ops(
18823                acknowledged
18824                    .iter()
18825                    .filter(|op| names.contains(op.table.as_str())),
18826            )?;
18827            for (commit, initial) in &self.failed_commits {
18828                for operation in &commit.ops {
18829                    if !names.contains(operation.table.as_str()) || !operation.upsert {
18830                        continue;
18831                    }
18832                    if let Some(table) = self.schema.table(&operation.table) {
18833                        if self.visible_row(table, &operation.row_id)?.is_none()
18834                            && operation.values.as_ref().is_some_and(|values| {
18835                                table
18836                                    .columns
18837                                    .iter()
18838                                    .all(|column| values.contains_key(&column.name))
18839                            })
18840                        {
18841                            let key = serde_json::to_string(&(
18842                                operation.table.clone(),
18843                                operation.row_id.clone(),
18844                            ))
18845                            .expect("row key serialization");
18846                            if let Some(Value::Object(values)) = initial.get(&key) {
18847                                self.apply_outbox_ops(&[OutboxOp {
18848                                    values: Some(values.clone()),
18849                                    ..operation.clone()
18850                                }])?;
18851                            }
18852                        }
18853                    }
18854                }
18855                self.apply_outbox_ops(
18856                    commit
18857                        .ops
18858                        .iter()
18859                        .filter(|op| names.contains(op.table.as_str())),
18860                )?;
18861            }
18862            for commit in &self.outbox {
18863                self.apply_outbox_ops(
18864                    commit
18865                        .ops
18866                        .iter()
18867                        .filter(|op| names.contains(op.table.as_str())),
18868                )?;
18869            }
18870            for table in &tables {
18871                for index in &table.fts_indexes {
18872                    self.rebuild_fts_projection(table, index)?;
18873                    self.create_fts_triggers(table, index)?;
18874                }
18875            }
18876            self.conn
18877                .execute_batch("RELEASE syncular_overlay")
18878                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18879            Ok(())
18880        })();
18881        if rebuilt.is_err() {
18882            self.conn
18883                .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
18884                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18885        } else {
18886            self.overlay_dirty.set(false);
18887        }
18888        rebuilt
18889    }
18890
18891    /// §7.1 replay: every operation is applied over the current visible
18892    /// state. An operation that no longer applies (a server row now holds its
18893    /// unique value) stays pending and invisible until a later replay admits
18894    /// it or the server answers its push.
18895    fn apply_outbox_ops<'a>(
18896        &self,
18897        ops: impl IntoIterator<Item = &'a OutboxOp>,
18898    ) -> Result<(), String> {
18899        #[cfg(feature = "bench-internals")]
18900        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
18901        for op in ops {
18902            #[cfg(feature = "bench-internals")]
18903            if let Some(phase) = &mut phase {
18904                phase.unit();
18905            }
18906            if op.upsert {
18907                if let (Some(table), Some(values)) = (
18908                    self.schema
18909                        .table(&op.table)
18910                        .filter(|table| table.indexes.iter().any(|index| index.unique)),
18911                    op.values.as_ref(),
18912                ) {
18913                    let mut intended: Map<String, Value> = self
18914                        .visible_row(table, &op.row_id)?
18915                        .map(|(row, _)| {
18916                            table
18917                                .columns
18918                                .iter()
18919                                .zip(&row)
18920                                .map(|(column, value)| {
18921                                    (column.name.clone(), column_value_to_json(value))
18922                                })
18923                                .collect()
18924                        })
18925                        .unwrap_or_default();
18926                    intended.extend(values.clone());
18927                    if !self.unique_conflicts(table, &intended, false)?.is_empty() {
18928                        continue;
18929                    }
18930                }
18931            }
18932            self.apply_outbox_op(op)?;
18933        }
18934        Ok(())
18935    }
18936
18937    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
18938        let Some(table) = self.schema.table(&op.table) else {
18939            return Ok(());
18940        };
18941        let visible = visible_table(&table.name);
18942        if !op.upsert {
18943            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
18944            self.conn
18945                .prepare_cached(&sql)
18946                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
18947                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18948            return Ok(());
18949        }
18950        let Some(values) = op.values.as_ref() else {
18951            return Ok(());
18952        };
18953        // §7.1: the overlay applies the operation's PRESENT columns
18954        // over the current local row. A partial operation over an
18955        // absent local row leaves it absent — the server answers it
18956        // with sync.row_deleted or sync.row_missing (§6.2).
18957        let full = table
18958            .columns
18959            .iter()
18960            .all(|column| values.contains_key(&column.name));
18961        let local = self.visible_row(table, &op.row_id)?;
18962        if local.is_none() && !full {
18963            return Ok(());
18964        }
18965        let mut row: Row = Vec::with_capacity(table.columns.len());
18966        for (index, column) in table.columns.iter().enumerate() {
18967            let incoming = match values.get(&column.name) {
18968                Some(raw) => json_to_column_value(
18969                    if raw.get("$bytes").is_some()
18970                        && table
18971                            .encrypted_columns
18972                            .iter()
18973                            .any(|encrypted| encrypted.index == index)
18974                    {
18975                        &table.wire_columns[index]
18976                    } else {
18977                        column
18978                    },
18979                    Some(raw),
18980                ),
18981                None => Ok(local
18982                    .as_ref()
18983                    .and_then(|(values, _)| values.get(index).cloned())
18984                    .flatten()),
18985            };
18986            row.push(incoming.map_err(|_| {
18987                "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
18988            })?);
18989        }
18990        let version = local.as_ref().map_or(-1, |(_, version)| *version);
18991        self.write_row(&visible, &table.name, &row, version)
18992            .map_err(|error| {
18993                if error.starts_with("UNIQUE constraint failed:") {
18994                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
18995                } else {
18996                    error
18997                }
18998            })
18999    }
19000
19001    /// The visible (optimistic) row's values plus version, or `None` when the
19002    /// row is absent from the overlay.
19003    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
19004        self.stored_row(table, row_id, false)
19005    }
19006
19007    fn stored_row(
19008        &self,
19009        table: &TableSchema,
19010        row_id: &str,
19011        base: bool,
19012    ) -> Result<Option<(Row, i64)>, String> {
19013        let sql = format!(
19014            "SELECT * FROM {} WHERE {}",
19015            if base {
19016                base_table(&table.name)
19017            } else {
19018                visible_table(&table.name)
19019            },
19020            row_id_predicate(table)
19021        );
19022        let mut stmt = self
19023            .conn
19024            .prepare_cached(&sql)
19025            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19026        let mut rows = stmt
19027            .query(rusqlite::params![row_id])
19028            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19029        let Some(row) = rows
19030            .next()
19031            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19032        else {
19033            return Ok(None);
19034        };
19035        let mut values = Vec::with_capacity(table.columns.len());
19036        for (index, column) in table.columns.iter().enumerate() {
19037            let raw = row
19038                .get_ref(index)
19039                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19040            if let rusqlite::types::ValueRef::Blob(bytes) = raw {
19041                if table
19042                    .encrypted_columns
19043                    .iter()
19044                    .any(|encrypted| encrypted.index == index)
19045                {
19046                    values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
19047                    continue;
19048                }
19049            }
19050            let json = sql_ref_to_json(column, raw);
19051            values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
19052                "sync.local_corrupt: persisted local row value is invalid".to_owned()
19053            })?);
19054        }
19055        let version: i64 = row
19056            .get(table.columns.len())
19057            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19058        Ok(Some((values, version)))
19059    }
19060
19061    // -- realtime (§8) ---------------------------------------------------------------
19062
19063    /// §8.8: the configured realtime policy.
19064    #[must_use]
19065    pub fn realtime_policy(&self) -> RealtimePolicy {
19066        self.realtime_policy
19067    }
19068
19069    /// §8.8: the explicit realtime availability state.
19070    #[must_use]
19071    pub fn realtime_state(&self) -> RealtimeState {
19072        self.realtime_state
19073    }
19074
19075    /// §8.8: set the realtime policy. Refused while the socket is connected:
19076    /// a policy change never silently re-routes an ownership state.
19077    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
19078        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
19079            return Err(
19080                "sync.invalid_request: disconnect realtime before changing the realtime policy"
19081                    .to_owned(),
19082            );
19083        }
19084        self.realtime_policy = policy;
19085        match policy {
19086            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
19087            RealtimePolicy::Required | RealtimePolicy::Optional
19088                if self.realtime_state == RealtimeState::Disabled =>
19089            {
19090                self.set_realtime_state(RealtimeState::Disconnected, None);
19091            }
19092            RealtimePolicy::Required | RealtimePolicy::Optional => {}
19093        }
19094        Ok(())
19095    }
19096
19097    /// Record an availability transition, keeping the reason code bounded and
19098    /// code-like (§7.6) and clearing it where it cannot apply.
19099    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
19100        self.realtime_state = state;
19101        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
19102        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
19103            self.realtime_reason_code = None;
19104            self.realtime_retry_delay_ms = None;
19105        }
19106    }
19107
19108    #[must_use]
19109    fn realtime_connected(&self) -> bool {
19110        self.realtime_state == RealtimeState::Connected
19111    }
19112
19113    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
19114        if !self.transport_enabled {
19115            return Err("sync.offline: transport gate is closed".into());
19116        }
19117        if self.realtime_policy == RealtimePolicy::Off {
19118            return Err(
19119                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
19120            );
19121        }
19122        if self.realtime_connected() {
19123            return Ok(());
19124        }
19125        self.realtime_state = RealtimeState::Connecting;
19126        match transport.realtime_connect_for_client(&self.client_id) {
19127            Ok(()) => {
19128                self.set_realtime_state(RealtimeState::Connected, None);
19129                Ok(())
19130            }
19131            Err(error) => {
19132                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
19133                Err(format!("{}: {}", error.code, error.message))
19134            }
19135        }
19136    }
19137
19138    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
19139        self.cancel_sync_round();
19140        // A deliberately disconnected or policy-disabled client has nothing
19141        // to release; a `lost` socket still does (the host owns the handle).
19142        if self.realtime_state == RealtimeState::Disconnected
19143            || self.realtime_state == RealtimeState::Disabled
19144        {
19145            return;
19146        }
19147        let _ = transport.realtime_close();
19148        self.set_realtime_state(
19149            if self.realtime_policy == RealtimePolicy::Off {
19150                RealtimeState::Disabled
19151            } else {
19152                RealtimeState::Disconnected
19153            },
19154            None,
19155        );
19156        self.presence.clear(); // §8.6.1: presence is per-connection
19157    }
19158
19159    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
19160    /// document for `scope_key`. Requires a live socket; the document is
19161    /// ephemeral (lost on disconnect). Authorization is the connection's
19162    /// registration (§8.6.3) — an unheld key is rejected loudly by the
19163    /// server with `presence.forbidden`.
19164    pub fn set_presence(
19165        &mut self,
19166        transport: &mut dyn Transport,
19167        scope_key: &str,
19168        doc: Option<&Value>,
19169    ) -> Result<(), String> {
19170        if !self.transport_enabled {
19171            return Err("sync.offline: transport gate is closed".into());
19172        }
19173        if !self.realtime_connected() {
19174            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
19175        }
19176        let text = encode_presence_publish(scope_key, doc);
19177        transport
19178            .realtime_send(&text)
19179            .map_err(|e| format!("{}: {}", e.code, e.message))
19180    }
19181
19182    /// §8.6: the peers currently present on a scope key (ephemeral).
19183    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
19184        self.presence
19185            .get(scope_key)
19186            .map(|peers| peers.values().cloned().collect())
19187            .unwrap_or_default()
19188    }
19189
19190    /// §8.6 apply an inbound presence fanout to the local map.
19191    fn apply_presence(
19192        &mut self,
19193        scope_key: String,
19194        kind: Option<PresenceKind>,
19195        actor_id: Option<String>,
19196        client_id: Option<String>,
19197        doc: Option<Value>,
19198        error: Option<String>,
19199    ) {
19200        // The publisher-directed error variant is out-of-band; nothing to
19201        // record in the peer map.
19202        if error.is_some() {
19203            return;
19204        }
19205        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
19206            return;
19207        };
19208        let peer_key = format!("{actor_id} {client_id}");
19209        match kind {
19210            PresenceKind::Leave => {
19211                if let Some(peers) = self.presence.get_mut(&scope_key) {
19212                    peers.remove(&peer_key);
19213                    if peers.is_empty() {
19214                        self.presence.remove(&scope_key);
19215                    }
19216                }
19217            }
19218            _ => {
19219                let doc = match doc {
19220                    Some(Value::Object(_)) => doc.unwrap(),
19221                    _ => return,
19222                };
19223                self.presence.entry(scope_key).or_default().insert(
19224                    peer_key,
19225                    PresencePeer {
19226                        actor_id,
19227                        client_id,
19228                        doc,
19229                    },
19230                );
19231            }
19232        }
19233    }
19234
19235    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
19236    pub fn on_realtime_text(&mut self, text: &str) {
19237        match parse_control(text) {
19238            Ok(ControlMessage::Hello { requires_sync, .. }) => {
19239                if requires_sync {
19240                    // §8.1: pull before trusting the socket for continuity.
19241                    self.set_sync_needed(true, true);
19242                }
19243            }
19244            Ok(ControlMessage::Presence {
19245                scope_key,
19246                kind,
19247                actor_id,
19248                client_id,
19249                doc,
19250                error,
19251                ..
19252            }) => {
19253                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
19254            }
19255            Ok(ControlMessage::Wake { .. }) => {
19256                // §8.3: any wake-up means "run a pull soon", never data.
19257                self.set_sync_needed(true, true);
19258            }
19259            _ => {}
19260        }
19261    }
19262
19263    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
19264    /// a pull response per section; an unapplied delta is a wake-up.
19265    /// Mailbox hosts send durable acknowledgements on their I/O executor.
19266    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
19267        let mut transport = crate::round::DownloadResults::default();
19268        self.on_realtime_binary(&mut transport, bytes);
19269        transport.controls
19270    }
19271
19272    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
19273        if self.stopped || !self.transport_enabled {
19274            return;
19275        }
19276        #[cfg(feature = "bench-internals")]
19277        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
19278        let message = match decode_message(bytes) {
19279            Ok(m) if m.msg_kind == MsgKind::Response => m,
19280            _ => {
19281                self.set_sync_needed(true, true);
19282                return;
19283            }
19284        };
19285        #[cfg(feature = "bench-internals")]
19286        drop(decode_phase);
19287        let mut frames = message.frames.into_iter();
19288        let mut applied_cursor: Option<i64> = None;
19289        let mut any_covered = false;
19290        let mut dropped = false;
19291        while let Some(frame) = frames.next() {
19292            let Frame::SubStart {
19293                id,
19294                status,
19295                effective_scopes,
19296                ..
19297            } = frame
19298            else {
19299                continue;
19300            };
19301            let mut body = Vec::new();
19302            let mut next_cursor: Option<i64> = None;
19303            for inner in frames.by_ref() {
19304                match inner {
19305                    Frame::SubEnd {
19306                        next_cursor: nc, ..
19307                    } => {
19308                        next_cursor = Some(nc);
19309                        break;
19310                    }
19311                    Frame::Unknown { .. } => {}
19312                    other => body.push(other),
19313                }
19314            }
19315            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
19316                dropped = true;
19317                continue;
19318            };
19319            let sub = &self.subs[sub_index];
19320            // §8.2: only locally active, not mid-bootstrap subscriptions
19321            // apply; skipped sections are repaired by the next pull.
19322            if status != SubStatus::Active
19323                || sub.state != SubState::Active
19324                || sub.bootstrap_state.is_some()
19325                || !sub.synced_once
19326            {
19327                dropped = true;
19328                continue;
19329            }
19330            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
19331                // Idempotent redelivery of an already-covered window.
19332                any_covered = true;
19333                continue;
19334            }
19335            let previous_effective = self.subs[sub_index].effective.clone();
19336            let previous_cursor = self.subs[sub_index].cursor;
19337            self.subs[sub_index].effective = Some(effective_scopes);
19338            let mut failed = false;
19339            for inner in body {
19340                match inner {
19341                    Frame::Commit {
19342                        tables,
19343                        changes,
19344                        commit_seq,
19345                        ..
19346                    } => {
19347                        if self
19348                            .apply_commit_frame(&tables, &changes, Some(commit_seq))
19349                            .is_err()
19350                        {
19351                            failed = true;
19352                            break;
19353                        }
19354                    }
19355                    _ => {
19356                        failed = true;
19357                        break;
19358                    }
19359                }
19360            }
19361            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
19362                self.apply_sub_end(sub_index, cursor, None)
19363                    .ok()
19364                    .map(|()| cursor)
19365            });
19366            let Some(next_cursor) = committed else {
19367                self.subs[sub_index].effective = previous_effective;
19368                self.subs[sub_index].cursor = previous_cursor;
19369                dropped = true;
19370                continue;
19371            };
19372            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
19373        }
19374        if let Some(cursor) = applied_cursor {
19375            // §8.2 ack point: the highest applied SUB_END.nextCursor.
19376            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
19377            let _ = transport.realtime_send(&ack);
19378        } else if !any_covered || dropped {
19379            // §8.2: a delta not applied at all is treated as a wake-up.
19380            self.set_sync_needed(true, true);
19381        }
19382    }
19383
19384    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
19385    /// cursor across active, non-bootstrapping subscriptions that have
19386    /// synced at least once. No such subscription, no ack.
19387    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
19388        if !self.realtime_connected() {
19389            return;
19390        }
19391        let floor = self
19392            .subs
19393            .iter()
19394            .filter(|s| {
19395                s.state == SubState::Active
19396                    && s.bootstrap_state.is_none()
19397                    && s.synced_once
19398                    && s.cursor >= 0
19399            })
19400            .map(|s| s.cursor)
19401            .min();
19402        if let Some(cursor) = floor {
19403            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
19404            let _ = transport.realtime_send(&ack);
19405        }
19406    }
19407}
19408
19409#[cfg(test)]
19410mod previous_version_wiring_tests {
19411    use super::*;
19412    use serde_json::{json, Value};
19413
19414    fn previous_version_schema(version: i32) -> Value {
19415        json!({
19416            "version": version,
19417            "tables": [{
19418                "name": "tasks",
19419                "primaryKey": "id",
19420                "columns": [
19421                    { "name": "id", "type": "string", "nullable": false },
19422                    { "name": "note", "type": "string", "nullable": true }
19423                ],
19424                "scopes": []
19425            }]
19426        })
19427    }
19428
19429    fn previous_version_limits() -> crate::api::ClientLimits {
19430        crate::api::ClientLimits {
19431            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
19432                enabled: true,
19433                ..Default::default()
19434            }),
19435            ..Default::default()
19436        }
19437    }
19438
19439    /// The configuration the live client carries, for a direct capture call.
19440    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
19441        previous_version_limits()
19442            .previous_version_context
19443            .expect("configured")
19444    }
19445
19446    /// Capture the container the way a bump would, so a read test starts from a
19447    /// container this client's own state will then decide about.
19448    fn recapture_previous_version(client: &SyncClient) {
19449        let replica_path = client
19450            .previous_version_replica_path()
19451            .expect("file-backed replica");
19452        let outcome = crate::previous_version::capture_previous_version_from_replica(
19453            &client.conn,
19454            &replica_path,
19455            2,
19456            2,
19457            &previous_version_config(),
19458            1,
19459        )
19460        .expect("capture");
19461        assert!(matches!(
19462            outcome,
19463            crate::previous_version::CaptureOutcome::Captured(_)
19464        ));
19465    }
19466
19467    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
19468        Subscription {
19469            id: "wiring-sub".to_owned(),
19470            table: "tasks".to_owned(),
19471            requested: Vec::new(),
19472            params: None,
19473            cursor,
19474            bootstrap_state: bootstrap_state.map(str::to_owned),
19475            state,
19476            reason_code: None,
19477            effective: None,
19478            synced_once: false,
19479        }
19480    }
19481
19482    #[test]
19483    fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
19484        let path =
19485            std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
19486        let path_str = path.to_str().expect("temp path");
19487        let schema = previous_version_schema(3);
19488        let mut older = previous_version_schema(2);
19489        older["tables"][0]["columns"] =
19490            json!([{ "name": "id", "type": "string", "nullable": false }]);
19491        let mut client = SyncClient::open_path(
19492            "downgrade".into(),
19493            &previous_version_schema(1),
19494            previous_version_limits(),
19495            path_str,
19496        )
19497        .expect("fresh");
19498        client
19499            .mutate(vec![Mutation::Upsert {
19500                table: "tasks".into(),
19501                values: Map::from_iter([
19502                    ("id".into(), json!("accepted")),
19503                    ("note".into(), json!("old")),
19504                ]),
19505                base_version: None,
19506            }])
19507            .expect("v1 write");
19508        client
19509            .recreate_with_schema(&schema)
19510            .expect("upgrade captures context");
19511        let commit = client
19512            .mutate(vec![Mutation::Upsert {
19513                table: "tasks".into(),
19514                values: Map::from_iter([
19515                    ("id".into(), json!("queued")),
19516                    ("note".into(), json!("v3-only")),
19517                ]),
19518                base_version: None,
19519            }])
19520            .expect("queued v3 write");
19521        let changes = client.conn.total_changes();
19522        let outbox = client.pending_commit_ids();
19523        assert!(outbox.contains(&commit));
19524        let descriptor = client.get_meta("localSchemaDescriptor");
19525        let container = format!(
19526            "{path_str}{}",
19527            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
19528        );
19529        let context = std::fs::read(&container).expect("captured context");
19530        assert!(client
19531            .recreate_with_schema(&older)
19532            .expect_err("refuse recreate")
19533            .starts_with("client.schema_downgrade:"));
19534        assert_eq!(client.schema.version, 3);
19535        assert_eq!(client.conn.total_changes(), changes);
19536        assert_eq!(client.pending_commit_ids(), outbox);
19537        assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
19538        assert_eq!(
19539            client
19540                .conn
19541                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19542                    row.get::<_, String>(0)
19543                })
19544                .expect("queued row"),
19545            "v3-only"
19546        );
19547        assert_eq!(std::fs::read(&container).expect("context remains"), context);
19548        drop(client);
19549        let before = std::fs::read(&path).expect("replica bytes");
19550        // open_path uses DELETE journaling. The identity open must refuse
19551        // before its normal WAL configuration changes this database.
19552        for entry in 0..3 {
19553            let error = match entry {
19554                0 => SyncClient::open_path(
19555                    "downgrade".into(),
19556                    &older,
19557                    previous_version_limits(),
19558                    path_str,
19559                ),
19560                1 => SyncClient::open_path_with_identity(
19561                    None,
19562                    &older,
19563                    previous_version_limits(),
19564                    path_str,
19565                ),
19566                _ => SyncClient::with_connection(
19567                    "downgrade".into(),
19568                    &older,
19569                    previous_version_limits(),
19570                    Connection::open(&path).expect("connection"),
19571                ),
19572            }
19573            .err()
19574            .expect("refuse open");
19575            assert!(error.starts_with("client.schema_downgrade:"));
19576            let conn = Connection::open(&path).expect("inspect");
19577            assert_eq!(
19578                conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
19579                    .expect("journal mode"),
19580                "delete"
19581            );
19582            drop(conn);
19583            assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
19584            assert_eq!(
19585                std::fs::read(&container).expect("context unchanged"),
19586                context
19587            );
19588        }
19589        let reopened = SyncClient::open_path(
19590            "downgrade".into(),
19591            &schema,
19592            previous_version_limits(),
19593            path_str,
19594        )
19595        .expect("compatible reopen");
19596        assert_eq!(reopened.pending_commit_ids(), outbox);
19597        assert_eq!(
19598            reopened
19599                .conn
19600                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19601                    row.get::<_, String>(0)
19602                })
19603                .expect("queued row remains"),
19604            "v3-only"
19605        );
19606        reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
19607        reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
19608        drop(reopened);
19609        let legacy = SyncClient::open_path(
19610            "downgrade".into(),
19611            &schema,
19612            previous_version_limits(),
19613            path_str,
19614        )
19615        .expect("legacy no-marker reopen");
19616        assert_eq!(legacy.pending_commit_ids(), outbox);
19617        assert!(!legacy.upgrading());
19618        assert_eq!(
19619            legacy
19620                .conn
19621                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19622                    row.get::<_, String>(0)
19623                })
19624                .expect("legacy row remains"),
19625            "v3-only"
19626        );
19627        drop(legacy);
19628        std::fs::remove_file(path).expect("remove replica");
19629        let _ = std::fs::remove_file(container);
19630    }
19631
19632    #[test]
19633    fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
19634        for marker in [
19635            "''",
19636            "'bad'",
19637            "'0'",
19638            "'-1'",
19639            "'2.5'",
19640            "'03'",
19641            "'3\n'",
19642            "' 3'",
19643            "'+3'",
19644            "'3e0'",
19645            "'2147483648'",
19646            "NULL",
19647            "X'33'",
19648        ] {
19649            let conn = Connection::open_in_memory().expect("connection");
19650            conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
19651            let error = SyncClient::with_connection(
19652                "marker".into(),
19653                &previous_version_schema(3),
19654                ClientLimits::default(),
19655                conn,
19656            )
19657            .err()
19658            .expect("refuse marker");
19659            assert!(
19660                error.starts_with("sync.local_corrupt:"),
19661                "{marker}: {error}"
19662            );
19663        }
19664        for fault in [
19665            "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
19666            "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
19667        ] {
19668            let mut client = SyncClient::new(
19669                "marker".into(),
19670                &previous_version_schema(3),
19671                ClientLimits::default(),
19672            )
19673            .expect("fresh");
19674            let commit = client
19675                .mutate(vec![Mutation::Upsert {
19676                    table: "tasks".into(),
19677                    values: Map::from_iter([("id".into(), json!("queued"))]),
19678                    base_version: None,
19679                }])
19680                .expect("queued write");
19681            client.conn.execute_batch(fault).expect("marker fault");
19682            let changes = client.conn.total_changes();
19683            let error = client
19684                .recreate_with_schema(&previous_version_schema(4))
19685                .expect_err("refuse corrupt marker");
19686            assert!(error.starts_with("sync.local_corrupt:"));
19687            assert_eq!(client.conn.total_changes(), changes);
19688            assert_eq!(client.schema.version, 3);
19689            assert_eq!(client.pending_commit_ids(), vec![commit]);
19690        }
19691        for setup in [
19692            "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
19693            "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
19694        ] {
19695            let path =
19696                std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
19697            let conn = Connection::open(&path).expect("connection");
19698            conn.execute_batch(setup)
19699                .expect("unreadable metadata fixture");
19700            drop(conn);
19701            let before = std::fs::read(&path).expect("before");
19702            let error = SyncClient::open_path_with_identity(
19703                None,
19704                &previous_version_schema(3),
19705                ClientLimits::default(),
19706                path.to_str().expect("path"),
19707            )
19708            .err()
19709            .expect("refuse unreadable marker");
19710            assert!(error.starts_with("sync.local_corrupt:"));
19711            assert_eq!(std::fs::read(&path).expect("after"), before);
19712            std::fs::remove_file(path).expect("remove fixture");
19713        }
19714    }
19715
19716    std::thread_local! {
19717        /// The pending concurrent upgrade a busy handler commits, so a lock
19718        /// collision deterministically becomes the marker race under test.
19719        static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
19720            const { RefCell::new(None) };
19721    }
19722
19723    fn commit_concurrent_upgrade(attempts: i32) -> bool {
19724        CONCURRENT_UPGRADE.with(|slot| {
19725            if let Some(banner) = slot.borrow_mut().take() {
19726                banner
19727                    .execute_batch(
19728                        "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
19729                    )
19730                    .expect("commit the concurrent upgrade");
19731            }
19732        });
19733        attempts < 10
19734    }
19735
19736    fn journal_mode(conn: &Connection) -> String {
19737        conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
19738            .expect("journal mode")
19739    }
19740
19741    fn temp_replica(label: &str) -> std::path::PathBuf {
19742        std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
19743    }
19744
19745    #[test]
19746    fn file_identity_constructor_requires_the_returned_wal_mode() {
19747        for path in [":memory:", ""] {
19748            let error = SyncClient::open_path_with_identity(
19749                None,
19750                &previous_version_schema(1),
19751                ClientLimits::default(),
19752                path,
19753            )
19754            .err()
19755            .expect("temporary SQLite databases cannot enter WAL mode");
19756            assert_eq!(
19757                error,
19758                "sync.invalid_request: file-backed client requires WAL journal mode"
19759            );
19760        }
19761        let path = temp_replica("confirmed-wal");
19762        let client = SyncClient::open_path_with_identity(
19763            None,
19764            &previous_version_schema(1),
19765            ClientLimits::default(),
19766            path.to_str().unwrap(),
19767        )
19768        .unwrap();
19769        assert_eq!(journal_mode(&client.conn), "wal");
19770        drop(client);
19771        std::fs::remove_file(path).unwrap();
19772    }
19773
19774    #[test]
19775    fn non_positive_generated_schema_versions_are_refused_before_storage() {
19776        for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
19777            let mut schema = previous_version_schema(1);
19778            schema["version"] = version.clone();
19779            let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
19780                .err()
19781                .expect("refuse an out-of-range generated version");
19782            assert_eq!(
19783                error,
19784                "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
19785            );
19786            // The file-backed seam refuses before the replica gains a table.
19787            let path = temp_replica("bad-version");
19788            let path_str = path.to_str().expect("path");
19789            for identity in [false, true] {
19790                let error = if identity {
19791                    SyncClient::open_path_with_identity(
19792                        None,
19793                        &schema,
19794                        ClientLimits::default(),
19795                        path_str,
19796                    )
19797                } else {
19798                    SyncClient::open_path(
19799                        "bad-version".into(),
19800                        &schema,
19801                        ClientLimits::default(),
19802                        path_str,
19803                    )
19804                }
19805                .err()
19806                .expect("refuse before storage");
19807                assert!(
19808                    error.starts_with("sync.invalid_request:"),
19809                    "{version}: {error}"
19810                );
19811                assert!(
19812                    !path.exists(),
19813                    "{version}: a refused constructor creates no replica file"
19814                );
19815            }
19816        }
19817        // A missing version is the same request error, not a parse failure.
19818        let mut schema = previous_version_schema(1);
19819        schema
19820            .as_object_mut()
19821            .expect("schema object")
19822            .remove("version");
19823        let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
19824            .err()
19825            .expect("refuse a missing version");
19826        assert!(error.starts_with("sync.invalid_request:"), "{error}");
19827    }
19828
19829    #[test]
19830    fn a_duplicated_schema_marker_is_refused_without_a_reset() {
19831        let path = temp_replica("duplicate-marker");
19832        let path_str = path.to_str().expect("path");
19833        {
19834            // A hand-built/legacy metadata table without its primary key, with
19835            // the older marker first: `query_row` would pick it and reset.
19836            let conn = Connection::open(path_str).expect("fixture");
19837            conn.execute_batch(
19838                "CREATE TABLE _syncular_meta(key TEXT, value);
19839                 INSERT INTO _syncular_meta VALUES
19840                   ('localSchemaVersion', '1'),
19841                   ('localSchemaVersion', '2'),
19842                   ('clientId', 'duplicate');",
19843            )
19844            .expect("duplicate marker fixture");
19845        }
19846        let error = SyncClient::open_path_with_identity(
19847            None,
19848            &previous_version_schema(3),
19849            ClientLimits::default(),
19850            path_str,
19851        )
19852        .err()
19853        .expect("refuse a duplicated marker");
19854        assert!(error.starts_with("sync.local_corrupt:"), "{error}");
19855        let probe = Connection::open(path_str).expect("probe");
19856        assert_eq!(
19857            probe
19858                .query_row(
19859                    "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
19860                    [],
19861                    |row| row.get::<_, i64>(0),
19862                )
19863                .expect("marker rows"),
19864            2,
19865            "the duplicated rows are left for the operator, not collapsed"
19866        );
19867        assert_eq!(
19868            probe
19869                .query_row(
19870                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
19871                    [],
19872                    |row| row.get::<_, i64>(0),
19873                )
19874                .expect("synced tables"),
19875            0,
19876            "a duplicated marker never drives the reset that recreates v3 tables"
19877        );
19878        assert_eq!(
19879            journal_mode(&probe),
19880            "delete",
19881            "the refusal leaves the journal mode alone"
19882        );
19883        drop(probe);
19884        std::fs::remove_file(path).expect("remove replica");
19885    }
19886
19887    #[test]
19888    fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
19889        let path = temp_replica("refused-wal");
19890        let path_str = path.to_str().expect("path");
19891        // `open_path` leaves the default journal mode, so a refused open is
19892        // the only thing that could switch this replica to WAL.
19893        drop(
19894            SyncClient::open_path(
19895                "owner".into(),
19896                &previous_version_schema(2),
19897                ClientLimits::default(),
19898                path_str,
19899            )
19900            .expect("v2 replica"),
19901        );
19902        let before = std::fs::read(&path).expect("before");
19903        let error = SyncClient::open_path_with_identity(
19904            Some("intruder".into()),
19905            &previous_version_schema(2),
19906            ClientLimits::default(),
19907            path_str,
19908        )
19909        .err()
19910        .expect("refuse the identity mismatch");
19911        assert!(error.starts_with("client.identity_mismatch:"), "{error}");
19912        let probe = Connection::open(path_str).expect("probe");
19913        assert_eq!(
19914            journal_mode(&probe),
19915            "delete",
19916            "a refused open must not persist WAL"
19917        );
19918        assert_eq!(std::fs::read(&path).expect("after"), before);
19919        assert_eq!(
19920            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
19921            Some("2")
19922        );
19923        drop(probe);
19924        std::fs::remove_file(path).expect("remove replica");
19925    }
19926
19927    #[test]
19928    fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
19929        let path = temp_replica("marker-race");
19930        let path_str = path.to_str().expect("path");
19931        drop(
19932            SyncClient::open_path_with_identity(
19933                Some("stale".into()),
19934                &previous_version_schema(1),
19935                ClientLimits::default(),
19936                path_str,
19937            )
19938            .expect("v1 replica"),
19939        );
19940        // Another process holds the write lock with an uncommitted upgrade; a
19941        // stale open reads the v1 marker first and only then meets the lock.
19942        // The first busy therefore lands after this replica became v2.
19943        let banner = Connection::open(path_str).expect("banner");
19944        banner
19945            .execute_batch(
19946                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
19947            )
19948            .expect("pending upgrade");
19949        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
19950        let conn = Connection::open(path_str).expect("stale connection");
19951        conn.busy_handler(Some(commit_concurrent_upgrade))
19952            .expect("busy handler");
19953        let error = SyncClient::with_connection(
19954            "stale".into(),
19955            &previous_version_schema(1),
19956            ClientLimits::default(),
19957            conn,
19958        )
19959        .err()
19960        .expect("a concurrent upgrade must refuse the stale open");
19961        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
19962        let probe = Connection::open(path_str).expect("probe");
19963        assert_eq!(
19964            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
19965            Some("2"),
19966            "the concurrent upgrade survives"
19967        );
19968        drop(probe);
19969        std::fs::remove_file(path).expect("remove replica");
19970    }
19971
19972    #[test]
19973    fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
19974        let path = temp_replica("recreate-race");
19975        let path_str = path.to_str().expect("path");
19976        let mut client = SyncClient::open_path_with_identity(
19977            Some("stale".into()),
19978            &previous_version_schema(1),
19979            ClientLimits::default(),
19980            path_str,
19981        )
19982        .expect("v1 replica");
19983        client
19984            .mutate(vec![Mutation::Upsert {
19985                table: "tasks".into(),
19986                values: Map::from_iter([("id".into(), json!("queued"))]),
19987                base_version: None,
19988            }])
19989            .expect("queued write");
19990        let commit = client.pending_commit_ids();
19991        let banner = Connection::open(path_str).expect("banner");
19992        banner
19993            .execute_batch(
19994                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
19995            )
19996            .expect("pending upgrade");
19997        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
19998        client
19999            .conn
20000            .busy_handler(Some(commit_concurrent_upgrade))
20001            .expect("busy handler");
20002        let error = client
20003            .recreate_with_schema(&previous_version_schema(1))
20004            .expect_err("a concurrent upgrade must refuse the stale recreate");
20005        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20006        assert_eq!(client.schema.version, 1, "the compiled schema is restored");
20007        assert_eq!(
20008            client.pending_commit_ids(),
20009            commit,
20010            "the outbox is preserved"
20011        );
20012        let probe = Connection::open(path_str).expect("probe");
20013        assert_eq!(
20014            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20015            Some("2")
20016        );
20017        drop(probe);
20018        drop(client);
20019        std::fs::remove_file(path).expect("remove replica");
20020    }
20021
20022    #[test]
20023    fn failed_schema_record_writes_roll_back_recreation() {
20024        for key in [
20025            LOCAL_SCHEMA_VERSION_KEY,
20026            crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
20027        ] {
20028            let mut client = SyncClient::new(
20029                "schema-record".into(),
20030                &previous_version_schema(1),
20031                ClientLimits::default(),
20032            )
20033            .expect("v1");
20034            let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
20035            let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
20036            client.conn.execute_batch(&format!("CREATE TRIGGER refuse_schema_record BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{key}' BEGIN SELECT RAISE(ABORT, 'refuse schema record'); END")).expect("fault");
20037            client
20038                .recreate_with_schema(&previous_version_schema(2))
20039                .expect_err("both record writes must propagate failure");
20040            assert_eq!(client.schema.version, 1);
20041            assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
20042            assert_eq!(
20043                client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
20044                descriptor
20045            );
20046            client
20047                .conn
20048                .execute_batch("DROP TRIGGER refuse_schema_record")
20049                .expect("remove fault");
20050            client
20051                .recreate_with_schema(&previous_version_schema(2))
20052                .expect("retry");
20053            assert_eq!(client.schema.version, 2);
20054        }
20055    }
20056
20057    #[test]
20058    fn a_failed_outer_commit_restores_every_reset_side_effect() {
20059        let path = temp_replica("outer-commit");
20060        let path_str = path.to_str().expect("path");
20061        // A rollback-journal replica (no WAL), so a second read connection can
20062        // hold SHARED across the recreated transaction's COMMIT.
20063        let conn = Connection::open(path_str).expect("replica connection");
20064        let mut client = SyncClient::with_connection(
20065            "outer-commit".into(),
20066            &previous_version_schema(1),
20067            previous_version_limits(),
20068            conn,
20069        )
20070        .expect("v1 replica");
20071        assert_eq!(journal_mode(&client.conn), "delete");
20072        let commit = client
20073            .mutate(vec![Mutation::Upsert {
20074                table: "tasks".into(),
20075                values: Map::from_iter([
20076                    ("id".into(), json!("queued")),
20077                    ("note".into(), json!("dropped-by-v2")),
20078                ]),
20079                base_version: None,
20080            }])
20081            .expect("queued write");
20082        client.drain_change_batches();
20083        // v2 drops `note`, so the queued upsert cannot re-encode.
20084        let mut dropped = previous_version_schema(2);
20085        dropped["tables"][0]["columns"] =
20086            json!([{ "name": "id", "type": "string", "nullable": false }]);
20087        // Prior database and memory state: the refused COMMIT must restore all
20088        // of it.
20089        let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
20090        let revision = client.local_revision();
20091        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
20092        let columns: i64 = client
20093            .conn
20094            .query_row(
20095                "SELECT count(*) FROM pragma_table_info('tasks')",
20096                [],
20097                |row| row.get(0),
20098            )
20099            .expect("column count");
20100        let rebuilds = client.overlay_rebuild_count.get();
20101        let prior_subs = client.subs.len();
20102        let prior_changes = client.change_queue.len();
20103        let prior_intents = client.sync_intent_queue.len();
20104        let prior_last_change = client.last_change.clone();
20105        let prior_overlay_dirty = client.overlay_dirty.get();
20106        let prior_sync_needed = client.sync_needed;
20107        let prior_upgrading = client.upgrading;
20108        let prior_stopped = client.stopped;
20109        let prior_schema_floor = client.schema_floor.clone();
20110        let prior_active_round = client.active_round;
20111        // The reader holds SHARED for the whole attempt: the writer takes
20112        // RESERVED, runs the reset, and fails when COMMIT needs EXCLUSIVE.
20113        let reader = Connection::open(path_str).expect("reader");
20114        reader
20115            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20116            .expect("reader snapshot");
20117        client
20118            .conn
20119            .busy_timeout(std::time::Duration::ZERO)
20120            .expect("busy timeout");
20121        let error = client
20122            .recreate_with_schema(&dropped)
20123            .expect_err("a blocked COMMIT must refuse the recreation");
20124        assert!(error.starts_with("client.storage_busy:"), "{error}");
20125        assert!(
20126            client.overlay_rebuild_count.get() > rebuilds,
20127            "the reset ran to its overlay rebuild inside the transaction"
20128        );
20129        assert!(client.conn.is_autocommit(), "the transaction is released");
20130        // Database: the rolled-back reset left nothing behind.
20131        assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
20132        assert_eq!(client.local_revision(), revision);
20133        assert_eq!(
20134            client.query("SELECT * FROM tasks", &[]).expect("rows"),
20135            rows
20136        );
20137        assert_eq!(
20138            client
20139                .conn
20140                .query_row(
20141                    "SELECT count(*) FROM pragma_table_info('tasks')",
20142                    [],
20143                    |row| { row.get::<_, i64>(0) }
20144                )
20145                .expect("column count"),
20146            columns,
20147            "the previous table layout survives the rollback"
20148        );
20149        assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
20150        // Memory: every side effect of the successful reset is restored with
20151        // the rolled-back database.
20152        assert_eq!(client.schema.version, 1, "compiled schema");
20153        assert_eq!(client.subs.len(), prior_subs);
20154        assert_eq!(client.change_queue.len(), prior_changes);
20155        assert_eq!(client.sync_intent_queue.len(), prior_intents);
20156        assert_eq!(client.last_change, prior_last_change);
20157        assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
20158        assert_eq!(client.sync_needed, prior_sync_needed);
20159        assert_eq!(client.upgrading, prior_upgrading);
20160        assert_eq!(client.stopped, prior_stopped);
20161        assert_eq!(client.schema_floor, prior_schema_floor);
20162        assert_eq!(client.active_round, prior_active_round);
20163        assert!(
20164            client.rejections.is_empty(),
20165            "the dropped-column rejection is rolled back too"
20166        );
20167        // The capture committed in its separate file before the failed replica
20168        // COMMIT. It belongs to v2 and cannot be exposed by the restored v1 core.
20169        let container = crate::previous_version::previous_version_container_path(path_str);
20170        assert!(std::path::Path::new(&container).exists());
20171        // The reader no longer needs its snapshot, so discard can clean the
20172        // replica's audit records along with the stale sibling file.
20173        drop(reader);
20174        let snapshot = client
20175            .previous_version_snapshot(&PreviousVersionReadSpec {
20176                table: "tasks".into(),
20177                ..Default::default()
20178            })
20179            .expect("refuse stale capture");
20180        assert!(!snapshot.available);
20181        assert_eq!(snapshot.current_version, 1);
20182        assert!(!std::path::Path::new(&container).exists());
20183        // The client remains usable: releasing the reader lets the identical
20184        // recreation run to completion, and the reset preserves the
20185        // incompatible intent until the send-time prepass classifies it
20186        // (§7.4.3/§7.4.4: the reset never touches the outbox).
20187        client
20188            .conn
20189            .busy_timeout(std::time::Duration::from_secs(1))
20190            .expect("busy timeout");
20191        client
20192            .recreate_with_schema(&dropped)
20193            .expect("the recreation succeeds once the reader releases");
20194        assert_eq!(client.schema.version, 2);
20195        assert_eq!(
20196            client.pending_commit_ids(),
20197            vec![commit.clone()],
20198            "the reset kept the incompatible commit pending"
20199        );
20200        assert!(
20201            client.rejections.is_empty(),
20202            "no rejection exists before the send-time prepass"
20203        );
20204        // The send boundary classifies and drops it.
20205        client
20206            .prepare_sync_round(false)
20207            .expect("the send prepass runs during round preparation");
20208        assert!(client.pending_commit_ids().is_empty());
20209        assert!(
20210            client
20211                .rejections
20212                .iter()
20213                .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
20214            "the dropped-column commit surfaced as a rejection at send time"
20215        );
20216        client.previous_version_discard();
20217        drop(client);
20218        std::fs::remove_file(path).expect("remove replica");
20219    }
20220
20221    #[test]
20222    fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
20223        let v1 = previous_version_schema(1);
20224        let mut v2 = previous_version_schema(2);
20225        // v2 drops `note`, so the queued `dropped` upsert cannot re-encode.
20226        v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
20227        let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
20228        let dropped = client
20229            .mutate(vec![Mutation::Upsert {
20230                table: "tasks".into(),
20231                values: Map::from_iter([
20232                    ("id".into(), json!("dropped")),
20233                    ("note".into(), json!("gone")),
20234                ]),
20235                base_version: None,
20236            }])
20237            .unwrap();
20238        // A full-row v1 upsert always carries the v1 columns, so the surviving
20239        // commit is a value-free delete (it encodes under any schema that still
20240        // has the table).
20241        let survivor = client
20242            .mutate(vec![Mutation::Delete {
20243                table: "tasks".into(),
20244                row_id: "gone".into(),
20245                base_version: None,
20246            }])
20247            .unwrap();
20248        client
20249            .recreate_with_schema(&v2)
20250            .expect("the reset preserves the outbox");
20251        assert_eq!(
20252            client.pending_commit_ids(),
20253            vec![dropped.clone(), survivor.clone()],
20254            "the reset preserves both commits"
20255        );
20256        assert!(client.rejections.is_empty(), "the reset classifies nothing");
20257        client.drain_change_batches();
20258        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20259        assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
20260
20261        // A fault anywhere in the atomic drop leaves the replica, the outbox,
20262        // the durable outcomes, the visible projection, the revision, and the
20263        // publish surface untouched. Clearing `syncNeeded` first keeps the
20264        // round's own status flip out of the assertion, so any published change
20265        // can only be the drop's.
20266        for fault in [
20267            "outcome-insert",
20268            "outbox-delete",
20269            "overlay-rebuild",
20270            "publish-revision",
20271        ] {
20272            client.set_sync_needed(false, false);
20273            client.drain_change_batches();
20274            let revision = client.local_revision();
20275            let trigger = match fault {
20276                "outcome-insert" => {
20277                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20278                }
20279                "outbox-delete" => {
20280                    "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20281                }
20282                // The optimized replay removes only the dropped projection, so
20283                // the replay fault belongs on the DELETE; a publish fault lands
20284                // on the revision write that `finish_observation` performs after
20285                // every durable effect already succeeded.
20286                "publish-revision" => {
20287                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20288                }
20289                _ => {
20290                    "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20291                }
20292            };
20293            client.conn.execute_batch(trigger).unwrap();
20294            let outcome = client
20295                .prepare_sync_round(false)
20296                .err()
20297                .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
20298            assert!(
20299                matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
20300                "{fault}: {outcome:?}"
20301            );
20302            client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
20303            assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
20304            assert_eq!(
20305                client.pending_commit_ids(),
20306                vec![dropped.clone(), survivor.clone()],
20307                "{fault}: the outbox is restored"
20308            );
20309            assert!(
20310                client.rejections.is_empty(),
20311                "{fault}: no in-memory rejection survives"
20312            );
20313            assert!(
20314                client.commit_outcome(&dropped).unwrap().is_none(),
20315                "{fault}: the durable outcome rolled back"
20316            );
20317            assert_eq!(client.local_revision(), revision, "{fault}: revision");
20318            assert_eq!(
20319                client.query("SELECT * FROM tasks", &[]).unwrap(),
20320                rows,
20321                "{fault}: the visible projection is unchanged"
20322            );
20323            assert!(
20324                client.drain_change_batches().is_empty(),
20325                "{fault}: nothing is published"
20326            );
20327        }
20328
20329        // A nested failure leaves the caller's transaction and earlier writes
20330        // intact; only the drop's savepoint is rolled back.
20331        client
20332            .conn
20333            .execute_batch(
20334                "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
20335             CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
20336             BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
20337            )
20338            .unwrap();
20339        assert!(client.drop_incompatible_outbox().is_err());
20340        assert!(!client.conn.is_autocommit());
20341        assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
20342        assert_eq!(
20343            client.pending_commit_ids(),
20344            vec![dropped.clone(), survivor.clone()]
20345        );
20346        assert!(client.commit_outcome(&dropped).unwrap().is_none());
20347        client
20348            .conn
20349            .execute_batch("DROP TRIGGER fail_drop; COMMIT")
20350            .unwrap();
20351
20352        // The successful drop publishes exactly once and leaves the surviving
20353        // intent queued for the same round. A log epoch is required before a
20354        // round builds push frames at all, so the classification provably runs
20355        // before any operation of the dropped commit is sent.
20356        client.set_meta(LOG_EPOCH_KEY, "epoch");
20357        client.set_sync_needed(false, false);
20358        client.drain_change_batches();
20359        let revision = client.local_revision();
20360        let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
20361        assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
20362        assert_eq!(
20363            prepared.meta.pushed_ids,
20364            vec![survivor.clone()],
20365            "the incompatible commit is classified before any operation is sent"
20366        );
20367        let rejection = client
20368            .rejections
20369            .iter()
20370            .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
20371            .expect("a sync.outbox_incompatible rejection");
20372        assert_eq!(rejection.client_commit_id, dropped);
20373        assert!(
20374            !rejection.retryable,
20375            "a schema-incompatible commit is final"
20376        );
20377        assert_eq!(
20378            client
20379                .commit_outcome(&dropped)
20380                .unwrap()
20381                .expect("a durable rejected outcome")
20382                .status,
20383            CommitOutcomeStatus::Rejected
20384        );
20385        assert_eq!(client.local_revision(), revision + 1, "one revision");
20386        let batches = client.drain_change_batches();
20387        assert_eq!(batches.len(), 1, "one change batch");
20388        assert!(
20389            batches[0].rejections_changed
20390                && batches[0].outcomes_changed
20391                && batches[0].status.is_some(),
20392            "{:?}",
20393            batches[0]
20394        );
20395        assert!(
20396            batches[0].tables.iter().any(|entry| entry.table == "tasks"),
20397            "{:?}",
20398            batches[0]
20399        );
20400        let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
20401        assert!(
20402            visible.is_empty(),
20403            "the dropped projection is undone: {visible:?}"
20404        );
20405
20406        // The observation's outer RELEASE (transaction COMMIT) is guarded too:
20407        // a rollback-journal reader holding SHARED makes it fail with the typed
20408        // busy classification, and releasing the reader lets the retry succeed.
20409        let path = temp_replica("outbox-drop-commit");
20410        let path_str = path.to_str().expect("path");
20411        let conn = Connection::open(path_str).expect("replica connection");
20412        let mut client =
20413            SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
20414                .expect("v1 replica");
20415        let dropped = client
20416            .mutate(vec![Mutation::Upsert {
20417                table: "tasks".into(),
20418                values: Map::from_iter([
20419                    ("id".into(), json!("dropped")),
20420                    ("note".into(), json!("gone")),
20421                ]),
20422                base_version: None,
20423            }])
20424            .unwrap();
20425        client
20426            .recreate_with_schema(&v2)
20427            .expect("the reset preserves the outbox");
20428        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
20429        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20430        assert_eq!(rows.len(), 1, "the optimistic row is visible");
20431        client.set_sync_needed(false, false);
20432        client.drain_change_batches();
20433        let revision = client.local_revision();
20434        let reader = Connection::open(path_str).expect("reader");
20435        reader
20436            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20437            .expect("reader snapshot");
20438        client
20439            .conn
20440            .busy_timeout(std::time::Duration::ZERO)
20441            .expect("busy timeout");
20442        let outcome = client
20443            .prepare_sync_round(false)
20444            .err()
20445            .expect("a blocked COMMIT must fail the round");
20446        assert!(
20447            matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
20448            "{outcome:?}"
20449        );
20450        assert!(client.conn.is_autocommit(), "the savepoint is released");
20451        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
20452        assert!(client.rejections.is_empty());
20453        assert!(client.commit_outcome(&dropped).unwrap().is_none());
20454        assert_eq!(client.local_revision(), revision);
20455        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
20456        assert!(client.drain_change_batches().is_empty());
20457        // The same round succeeds once the reader releases.
20458        drop(reader);
20459        client
20460            .conn
20461            .busy_timeout(std::time::Duration::from_secs(1))
20462            .expect("busy timeout");
20463        client
20464            .prepare_sync_round(false)
20465            .expect("the drop succeeds after the reader releases");
20466        assert!(client.pending_commit_ids().is_empty());
20467        assert!(client
20468            .rejections
20469            .iter()
20470            .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
20471        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
20472        drop(client);
20473        std::fs::remove_file(path).expect("remove replica");
20474    }
20475
20476    #[test]
20477    fn previous_version_read_reasons_come_from_live_client_state() {
20478        use crate::previous_version::{
20479            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
20480        };
20481        let path = std::env::temp_dir()
20482            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
20483            .to_string_lossy()
20484            .into_owned();
20485        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
20486        {
20487            let mut setup = SyncClient::open_path(
20488                "wiring".to_owned(),
20489                &previous_version_schema(1),
20490                previous_version_limits(),
20491                &path,
20492            )
20493            .expect("v1 install");
20494            setup
20495                .mutate(vec![Mutation::Upsert {
20496                    table: "tasks".to_owned(),
20497                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
20498                    base_version: None,
20499                }])
20500                .expect("seed a row");
20501        }
20502        let mut client = SyncClient::open_path(
20503            "wiring".to_owned(),
20504            &previous_version_schema(2),
20505            previous_version_limits(),
20506            &path,
20507        )
20508        .expect("v2 bump captures");
20509        assert!(std::path::Path::new(&container).exists());
20510        let spec = PreviousVersionReadSpec {
20511            table: "tasks".to_owned(),
20512            ..Default::default()
20513        };
20514
20515        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
20516        recapture_previous_version(&client);
20517        client.lease_state = Some(LeaseState {
20518            lease_id: None,
20519            expires_at_ms: None,
20520            error_code: Some("lease.expired".to_owned()),
20521        });
20522        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20523        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
20524        assert!(!snapshot.available);
20525        assert!(!std::path::Path::new(&container).exists());
20526
20527        // An expired lease uses the client clock (injected, no sleeps).
20528        recapture_previous_version(&client);
20529        client.set_now_ms(1_000);
20530        client.lease_state = Some(LeaseState {
20531            lease_id: Some("lease".to_owned()),
20532            expires_at_ms: Some(1_000),
20533            error_code: None,
20534        });
20535        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20536        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
20537        assert!(!std::path::Path::new(&container).exists());
20538        client.lease_state = None;
20539
20540        // A revoked subscription ⇒ scope-revoked.
20541        recapture_previous_version(&client);
20542        client.subs.push(subscription(SubState::Revoked, 0, None));
20543        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20544        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
20545        assert!(!std::path::Path::new(&container).exists());
20546        client.subs.clear();
20547
20548        // Coverage completion: every ACTIVE subscription has a cursor and no
20549        // resume token — a still-resuming subscription is not completion.
20550        recapture_previous_version(&client);
20551        client
20552            .subs
20553            .push(subscription(SubState::Active, 0, Some("resume")));
20554        assert!(!client.previous_version_coverage_complete());
20555        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20556        assert!(snapshot.available);
20557        assert_eq!(snapshot.previous_version, Some(2));
20558        assert_eq!(snapshot.rows.len(), 1);
20559
20560        recapture_previous_version(&client);
20561        client.subs[0].bootstrap_state = None;
20562        assert!(client.previous_version_coverage_complete());
20563        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20564        assert_eq!(
20565            snapshot.reason,
20566            Some(PreviousVersionReason::CoverageComplete)
20567        );
20568        assert!(!std::path::Path::new(&container).exists());
20569        client.subs.clear();
20570
20571        // With no active subscription at all, coverage is NOT complete.
20572        assert!(!client.previous_version_coverage_complete());
20573
20574        drop(client);
20575        let _ = std::fs::remove_file(&path);
20576    }
20577
20578    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
20579    /// come from live state; this proves the OTHER caller —
20580    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
20581    /// actually removes the container for lease-inactivity, coverage
20582    /// completion and scope revocation, and leaves it alone while the
20583    /// replacement bootstrap is still resuming.
20584    #[test]
20585    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
20586        let path = std::env::temp_dir()
20587            .join(format!(
20588                "syncular-prev-lifetime-{}.db",
20589                uuid::Uuid::new_v4()
20590            ))
20591            .to_string_lossy()
20592            .into_owned();
20593        let container = format!(
20594            "{path}{}",
20595            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
20596        );
20597        let exists = || std::path::Path::new(&container).exists();
20598        {
20599            let mut setup = SyncClient::open_path(
20600                "lifetime".to_owned(),
20601                &previous_version_schema(1),
20602                previous_version_limits(),
20603                &path,
20604            )
20605            .expect("v1 install");
20606            setup
20607                .mutate(vec![Mutation::Upsert {
20608                    table: "tasks".to_owned(),
20609                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
20610                    base_version: None,
20611                }])
20612                .expect("seed a row");
20613        }
20614        let mut client = SyncClient::open_path(
20615            "lifetime".to_owned(),
20616            &previous_version_schema(2),
20617            previous_version_limits(),
20618            &path,
20619        )
20620        .expect("v2 bump captures");
20621        // Pin the clock so the 24h TTL never fires in this test (the TTL has
20622        // its own coverage); the capture record's createdAtMs is 1.
20623        client.set_now_ms(1_000);
20624        assert!(exists());
20625
20626        // §7.3.5 lease error ⇒ the lifetime check drops the container.
20627        client.lease_state = Some(LeaseState {
20628            lease_id: None,
20629            expires_at_ms: None,
20630            error_code: Some("lease.expired".to_owned()),
20631        });
20632        client.previous_version_lifetime_check();
20633        assert!(!exists());
20634        client.lease_state = None;
20635
20636        // §7.4.5 coverage completion (every active sub has a cursor and no
20637        // resume token) ⇒ drop.
20638        recapture_previous_version(&client);
20639        client.subs.push(subscription(SubState::Active, 0, None));
20640        client.previous_version_lifetime_check();
20641        assert!(!exists());
20642        client.subs.clear();
20643
20644        // §3.3 scope revocation ⇒ drop.
20645        recapture_previous_version(&client);
20646        client.subs.push(subscription(SubState::Revoked, 0, None));
20647        client.previous_version_lifetime_check();
20648        assert!(!exists());
20649        client.subs.clear();
20650
20651        // Negative control: a still-resuming active subscription is NOT
20652        // coverage completion, so the container survives the same call.
20653        recapture_previous_version(&client);
20654        client
20655            .subs
20656            .push(subscription(SubState::Active, 0, Some("resume")));
20657        client.previous_version_lifetime_check();
20658        assert!(exists());
20659        client.subs.clear();
20660
20661        drop(client);
20662        let _ = std::fs::remove_file(&path);
20663        let _ = std::fs::remove_file(&container);
20664        let _ = std::fs::remove_file(format!("{container}-wal"));
20665        let _ = std::fs::remove_file(format!("{container}-shm"));
20666    }
20667}